What the follow-up (nudge) engine does today, what it ignores that we already know, and the shape of the work a future session would build — with the decisions that need Fede first.
Investigation only · 2026-09-20 · code read at ~/.claude/propflowai-gc-replay3 (main @ 43002444db, read-only) · numbers from live reads of DynamoDB propflow-prod (us-east-1) · every behaviour claim cites file:line
Contents
Decided 2026-10-14 (Fede): a scheduled touch never sends blind from a timer. The timer wakes Clara's loop for that conversation with no text; the loop reads the thread and decides whether to send, skip, or close. First instance: the staff tour check-in (origin: Kat gave Natalie Haynes's tour notes at 12:48 PM and the 2:00 PM "how was the tour?" check-in fired anyway). Prospect follow-ups move to the same pattern next. PR: pending.
In this section
Greg was handed to Kat on a call, toured, and was applying. Clara still texted him “Would love to have you come by.” Six different senders each decide on their own, and none sees the whole picture.
Every sender joins the same check: no-reply follow-ups, follow-ups after a reply, first replies to listing inquiries, tour messages, texts after a call, escalation callbacks.
No new lists or timers: a staff promise like “I’ll resend the application” is context the check reads, never a reminder.
Shares one reader with the platform vision; the shared reader and staff-alert model live there. Any text written from tour or staff notes goes through the same staff-only filter.
So AppFolio notes matter; we just haven’t labeled with them yet.
| Version | Bad texts caught | Good leads wrongly skipped |
|---|---|---|
| Today | 2 of 31 | 0 of 195 |
| “Send unless something says no” check, reading what we already have | 31 of 31 (3 runs, identical) | 19 of 195 (13–14 of the 23 tricky ones) |
The 19 is not trustworthy yet. The test cut long messages at 500 characters (7 cases), and the “should send” labels were set without AppFolio notes, so some “wrong skips” may be right. Fixing the test comes first.
Weak spot: JP&Co tricky leads (7–8 of 16 wrongly skipped).
| # | Step | What it adds | Size | Gate |
|---|---|---|---|---|
| 0 | Fix the test: no cutting messages, re-label with AppFolio notes, re-score | Trustworthy numbers | 1 day | None (local only) |
| 1 | Minimum slice: one check in front of the six follow-up senders, reading all of the lead’s Clara conversations, tour bookings and AppFolio card status; stop / wait / send | Catches all 16 real bad texts in testing | 2–3 days | Ships live once the fixed test shows wrongly skipped good leads near zero. No switch (a fix to live behavior). |
| 2 | Route the three senders that bypass everything (texts after a call, escalation callback, tour messages) through the same check | Same coverage for every message | 2 days | After step 1 |
| 3 | Write the text from the conversation instead of the generic one | Fixes “robotic and generic” | 3–5 days | Quality check passes 77% today; needs a higher bar first |
| 4 | Bring AppFolio activity notes into the lead picture | Staff calls and showings logged only in AppFolio (17 of 58 Western Slope leads) | 1 week | Careful with AppFolio request limits |
| 5 | Turn on the AppFolio staff-text feed per customer | Staff texts sent from AppFolio | Days | Turning it on at a customer is Fede’s call |
Separate, small, and each needs Fede’s go.
Start with step 0 (fix the test), then step 1? yes / hold.
onProspectEngaged() — src/lib/domain/leasing/outreach-trigger.ts:319. Fired after Clara's first reply on a text/email turn (agents/clara/lib/agent/conversation-manager.ts:13587), after a leasing call ends (src/app/api/voice/call-ended/route.ts:2460), and by a nightly self-healing reconciler (outreach-reconcile.ts:609,740,825).src/lib/domain/leasing/outreach-cadence-schedule.ts:101-106. Step 1 of the "5-step" story is Clara's first reply, sent outside the cadence.prefer_sms; falls back to email only if no texting consent, and is dropped entirely if neither exists — resolve-outreach-channel.ts:75-110.outreach-cadence-schedule.ts:53), quiet hours 8am–9pm property-local (prospect-outreach-workflow.ts:349,418), 12h cooldown if anything else already texted that person (autonomous-outreach-cooldown.ts:26,125).outreach-trigger.ts:12-17). The only operator lever is an org-level cadence editor that sets step delays/channels — FOLLOWUP#<orgId> / CADENCE#<scenarioId> (followup-cadence-overrides.ts:24), states default | custom | disabled.outreach-stop-conditions.ts:47: 'applied' | 'tour_booked' | 'closed'. Applied = appliedAt, householdAppliedAt, or stage applied/approved/lease_signed (:67-71). Tour booked = stage tour_pending/tour_confirmed, or any tourId that isn't cancelled (:82-84). Closed = stage rejected or not_interested (:90). A cancelled tour deliberately restarts the cadence (:94).src/lib/temporal/activities/prospect-outreach.ts:156.generate-followup-message.ts:447-549, called from prospect-outreach.ts:786,936. The hardcoded strings (src/lib/i18n/templates.ts:965-1001) are safety fallbacks only.generate-followup-message.ts:408-411), and anti-fabrication guards force the generic fallback if the copy names a price, a unit type, a policy (vouchers/income/evictions), or a tour time (:123-125, :214-215, :225-241, :259-264).prospect-outreach.ts:656-662: first name, property name, touch index, desiredBedrooms, desiredMoveIn, days since inquiry, Clara's own summary notes (aiNotes), language, and an optional grounded price drop. That's the whole context.The guest card carries these and the follow-up never reads any of them:
| We store | Where | Passed to the touch? |
|---|---|---|
Pets (leadFacts.pets) | types.ts:18357 | No. The petsMentioned slot exists (generate-followup-message.ts:56) and renders into the prompt (:434) — the outreach lane just never sets it, so it is always "no". |
Budget / income (leadFacts.budget, monthlyIncome) | types.ts:18363, 18694 | No — no field on the context at all. |
The home they asked about (targetUnitId, targetHomeAddress, targetListingId) | types.ts:18649, 18667, 18691 | No. Never read by the activity or the generator. |
| Whether that home is still available | resolveListingCardForReply() — knowledge/listing-card-for-reply.ts:113-142 | No. No availability read exists anywhere in sendOutreachTouchActivity. |
| The actual conversation | conversationTail field exists (generate-followup-message.ts:74) | No — documented "reengagement only", never populated by this lane. |
| Lease length, credit band, free-text preferences | types.ts:18359, 18361, 18401 | No. |
| Bedrooms, move-in date | types.ts:18318, 18330 | Yes — the only two needs that reach the copy. |
Two structural gaps behind this:
agents/clara/lib/prompts/clara-email-core.ts:393-405) plus message text. No attribute, no reason code. Answering it later means re-running resolveListingCardForReply or grepping the message body.src/lib/domain/insights/signals/tour-no-show.ts:7-13 says so outright; only a 14-day non-conversion proxy exists. So "toured and went quiet" and "booked and never came" look identical.propflow-prod, property 1773625953462 / org_jpco)Population, last 60 days (inquiries with inquiryDate >= 2026-07-22, query: GSI phone-index, GSI1PK=PROPERTY#1773625953462, begins_with(GSI1SK,'INQUIRY#')):
outreachCadenceAnchorIso present); 66 have claraEngagedAt.cooldown, zero other skip reasons.To reach a ≥30 sample I widened to every touched prospect in the table (34, inquiries 2026-06-01 → 2026-09-18). Classification of the state each prospect was in at their first touch, from the conversation transcript (PK=CONV#<id>, SK prefix MSG#) plus the inquiry row:
| State when touched | Count | How I detected it |
|---|---|---|
| Ordinary quiet — lead email in, Clara replied, no answer | 26 | exactly one inbound message (the portal lead email) before the first touch |
| Had a live back-and-forth, then went quiet | 6 | ≥2 inbound messages before the first touch |
| Home/unit gone or unavailable | 0 | regex for "no longer available / already leased / off the market" across all 34 transcripts: zero matches |
| We said no to a hard need (pets, budget, bedrooms, move-in, voucher) | 0 | regex for pet/income/voucher refusals: zero real matches (the one voucher thread, aa4f20e9, ended in yes, vouchers accepted) |
| Already applied / approved / signed when touched | 0 | compared every sent-touch timestamp against appliedAt, approvedAt, signedAt, pmsApplicationReceivedAt |
| Tour already booked when touched | 0 | compared every sent-touch timestamp against Tour.createdAt for confirmed tours on the same personId |
| Prospect said "not interested" | 0 | regex over transcripts |
| Prospect never got an answer to their question | 0 | every thread has a Clara reply before the first touch |
Touches that went to a plainly-dead lead or one we had already said no to: 0 of 62. The three stop conditions that exist are working. Camellia does not exercise the failure mode Fede is worried about — and that is the finding: Camellia is one building with fungible studios and 1-beds, so "the home they asked about" never goes away. Western Slope, scattered single-family homes, has no equivalent safety.
Five concrete examples (record ids are ProspectInquiry.id; key PK=PERSON#<personId>, SK=INQUIRY#<id>):
c247fb86-edc5-4afd-a773-62e086ffb49e (Erling, Apartments.com, 2026-08-30). His lead said "(Unit 524/Model 1BR/1.0BA) … particularly on the 5th floor." The +24h touch said: "Thanks for reaching out about a 1-bed with us." Unit 524 and the 5th floor vanish. He booked a tour off the second touch. The cadence worked — it just threw away the one thing he told us.d2123015-fcfd-5e7e-8247-4728ccd41f35 (Crystal, Zillow, 2026-09-14). Two generic touches; she replied to the second and booked. Touch 2 said "check in and see if you had any questions about us" — no reference to the studio/1BR pricing Clara had quoted her by email a day earlier.da49aa9e-18b2-43a8-9c38-c32cb5affdac (Sumanth, 2026-08-01). Asked "how much time from Union Station". The follow-up: "we'd love to show you around in person." The distinctive thing he cared about is not referenced.aa4f20e9-2e37-4e8c-a0f8-d6b07bcc7ba6 (Daniel Brehm, 2026-08-25). The one voucher case. He was told at his tour that we don't take vouchers; Clara corrected it the same evening ("Camellia absolutely accepts housing choice vouchers"), then walked it back, then confirmed. No touches followed — the tour had already stopped the cadence. Not harm; it is the case that shows voucher status is never a stored fact (guest-card-note-classifier.ts:191 forces it internal-only as a protected class).fe11da47-dfd7-411d-8a21-b363a198d7e0 (Summer, 2026-08-31). Clara told her parking is full with a waitlist, then sent all four generic touches. The follow-ups never acknowledge the one friction point in the thread.The real repetition defect is in the post-tour chase, not the cadence. 16 of 77 Camellia tours received ≥2 post-tour "did you apply?" chases and 13 received exactly 3 (Tour.postTourChasesSent, types.ts:11910). Those three messages are byte-identical. Two proven cases, from PK=CONV#<conversationId> / SK prefix MSG#:
da49aa9e-18b2-43a8-9c38-c32cb5affdac (Sumanth), tour TOUR# on person pers_…, date 2026-08-03 — identical text at 2026-08-04T23:30Z, 2026-08-06T23:30Z, 2026-08-10T23:30Z: "Hi Sumanth, it's Clara from Camellia Apartments! Did you get a chance to apply? You can start here whenever you're ready: https://www.camelliadenver.com/#floor-plans Happy to answer any questions."c247fb86-edc5-4afd-a773-62e086ffb49e (Erling), tour 2026-09-04 — same sentence at 2026-09-05T18:00Z, 2026-09-07T18:00Z, 2026-09-11T18:00Z.No coworker sends the same sentence three times.
Scope note: cadence state exists in four prod orgs — org_jpco 87 inquiries, org_53047a06-…9149a 46, org_c42d9150-…acf38 15, org_sandbox 290 (full-table scan, 1.6M rows, filter entityType=ProspectInquiry). Western Slope's real org has no cadence traffic yet; only org_ws_mirror_test (2). So today's engine has been proven on exactly one building.
Ordered by how often they will bite Western Slope. "Buildable today" means every input already exists in prod.
| # | Scenario | Detect it with | Buildable today? |
|---|---|---|---|
| 1 | The home they asked about is rented or delisted, and it's the only one they named. | resolveListingCardForReply(property, targetHomeAddress, today, targetListingId) returns kind:'none' — knowledge/listing-card-for-reply.ts:113-142. | Yes for scattered-site orgs. No for building properties — that helper short-circuits to '' at :124, and there is no per-unit "is this unit still offerable" predicate; nearest is canTourUnit() (future-availability.ts:177). |
| 2 | We already told them it's gone and offered alternatives, and they didn't answer. Re-pinging is the "are you still interested in the thing I told you doesn't exist" message. | Today only by string-matching the outbound body for the mandated phrase "is no longer available" (clara-email-core.ts:393-405). Needs a stored fact. | Needs a new signal (small). |
| 3 | We said no to a hard requirement: pet not allowed, bedroom count we don't have, move-in date we can't meet, rent above what we have. | Nothing structural exists. leadFacts.pets/budget, desiredBedrooms, desiredMoveIn are stored (types.ts:18318,18330,18357,18363) but no refusal is ever recorded. | Needs a new signal. |
| 4 | They told us they're out — "found a place", "not interested", "stop". | Only if something writes stage not_interested/rejected (outreach-stop-conditions.ts:90). Nothing parses reply text for it in the stage-1 lane. The stage-2 lane already has the classifier: reengagement-intent.ts:80,111,145,157,177 produces lead_will_initiate | deferred_to_future | not_interested | we_owe_reply | ambiguous and it fired 8 times in 60 days at Camellia. | Reuse the existing classifier — cheap. |
| 5 | They asked us something we never answered. Nudging before answering is the rudest version. | reengagementSuppressedSignal = 'we_owe_reply' already exists (types.ts:18917) but only in the stage-2 lane. | Reuse — cheap. |
| 6 | They toured and didn't come back — needs a different message than "want to take a look?", which we already showed them. | A confirmed tour stops the stage-1 cadence (:82-84), so this is handled — but the post-tour chase then sends the same sentence 3×. | Yes, copy fix. |
| 7 | Move-in date has passed. Someone who needed a home by Aug 28 does not want a nudge on Sep 20. | desiredMoveIn vs today. Trivially available, never checked. | Yes, trivial. |
| 8 | Nothing in the portfolio matches them today (no 3-beds, nothing under their budget, no pet-friendly home). Re-engaging with "still looking?" when we have nothing to show is a wasted text. | getOrgOfferableHomes() / getCompanyLineHomes() (org-offerable-homes.ts:172, company-line-homes.ts:94) + desiredBedrooms/budget. Both return null for refusal when the roster is stale (>7 days), which must not be read as "nothing available". | Yes for scattered-site. |
| 9 | They're already a resident, a vendor, or staff. | Already handled at enrolment — outreach-enrollment-identity-gate.ts:48-101. | Done. |
Camellia never needed this because "everyone can just apply to the community". Western Slope is different asset types and you have to match a person to a home, so "there has to be some basic logic of: are they even eligible, do we match them to something they want, are they a good fit… if we're not what they're looking for, we shouldn't be just following up blindly."
Fede, 2026-09-20
Ruling (Fede, 20 Sep 2026, 5 PM, after the team sample below): a short-term lease ask is answered with one sentence that it is something they can check with the leasing team, plus the usual tour invite and one note on the card. Clara does not state that 12 months is standard and does not say no; the lead is followed up normally. The broader 'a policy no filters the funnel' idea below stays provisional for other criteria (income, pets, credit). When the client's knowledge base answers a lead's ask—for example, a six-month lease at Western Slope, whose policy is 12 months standard with 14–18 month promotional terms at Ember Estates—Clara answers flat in one sentence with no deferral, leaves one plain note on the guest card ("Asked for a six-month lease; told them we don't offer it, 12 months standard"), nothing routed, no task, no alert, and the follow-up cadence does not chase that lead. Humans see the note only if they open the card. The same rule covers every objective policy or screening criterion the client gave us (income at about twice the rent, pets, credit or rental history): when a lead's message shows they don't meet it, Clara says so plainly so they don't waste time applying and getting declined. The one exception is fair housing: Clara never pre-screens on protected characteristics or source of income; a voucher or any lawful income counts as income, and on a judgment call she states the criterion and lets the lead decide. "we're helping filter the funnel to connect people only that match our policies to our open homes." (Fede, 2026-09-20) Sequenced after home matching (Lane B3).
Coverage: Western Slope — 1,226 guest cards read (~96% of all of 2026); 49 cards from Sep 9–19, 2026 could not be pulled (login/2FA gap, open follow-up). Situs Group — all 1,641 known cards captured, 1,096 of them in the 12-month window.
The earlier sample undercounted because it only looked for a short-lease ask written in the prospect's prose and ignored the lease length the renter picks on the Zillow / Apartments.com / Rent.com inquiry form itself — that field is the renter's own choice, not a system default, and this recount treats every value under 12 months as a real ask.
| Value | Western Slope (n=1,226 read) | Situs Group (n=1,096 in-window) |
|---|---|---|
| 1 month | 3 | 0 |
| 6 months | 1 | 0 |
| 9 months | 0 | 0 |
| 12 months | 25 | 16 |
| 18 months | 8 | 4 |
| 24+ months | 0 | 0 |
| Not stated (no field / non-Zillow-style source) | 1,189 | 1,076 |
| Client | Card | Date | Source | The ask | Team's reply (verbatim) | Mentions term? | Outcome |
|---|---|---|---|---|---|---|---|
| WS | R. | 09/01/2026 | Zillow | Field: 1 mo; wanted a furnished unit tour | "Hi, thank you for your interest in Northridge Apartments. What is your best time for a showing? -Bev" | No | Tour offered anyway, term never addressed |
| WS | T. | 09/03/2026 | Zillow | Field: 6 mo | Team did a virtual tour of the 2-bed unit (call note 09/05) | No | Tour offered anyway, term never addressed |
| WS | M. | 08/25/2026 | Zillow | Field: 1 mo | None logged | — | No reply |
| WS | B. | 08/14/2026 | Zillow | Field: 1 mo | None logged | — | No reply |
| WS | M. | 04/15/2026 | prose (existing tenant, near lease end) | "I'm wondering if it would be possible to do a month to month in the off chance that we can't find anything before the lease is up." | "Not a problem! The month-to-month fee will be $150 on top of your recurring fees… Congratulations on the baby! -Beverly" | Yes | Accepted |
| WS | W. | unspecified 2026 | prose (underwriting) | Team-proposed 6-month term as an approval condition | "We would propose a 6-month lease with double security deposit… and would still offer you the rent concession equal to a half month." | Yes | Countered (team-initiated, not a prospect ask) |
| WS | A. | 12/01/2025 | prose | "Oh that's a plus, could we do a 6 month lease" | "You can but then it's only a free half month." | Yes | Accepted (reduced concession) |
| WS | B. | 08/18/2026 | prose (existing tenant, holdover) | "I was wondering about the lease situation for the one month" | "the month to month fee of $150 will be added automatically and yes the security deposit stays with the house." | Yes | Accepted |
| WS | C. | unspecified 2026 | prose (renewal-adjacent) | Team-initiated 6-month term with reduced concession | "my boss said we can offer free half month rent since they're only doing a 6 month lease." | Yes | Accepted |
| WS | M. | 03/13/2026 | prose (existing tenant, holdover) | "we may need to stay a couple months month-to-month" | "I appreciate you letting us know… You mentioned before that you will be staying for a couple of months on a month-to-month basis. I sent you an email with the unit price this year." | Yes | Accepted |
| WS | K. | 03/17/2026 | Realtor.com | "6 months is ideal, but can work with more or less. We can pay 6 months up front" | "Congratulations on the sale! I have rented the unit… I'd only be able to let you have that for 1 1/2 months. I do have a 4 bed 2 1/2 bath house… coming by hopefully April 1st?" | Yes | Countered (offered an alternative unit/timeline) |
| WS | B. | unspecified 2026 | prose | Team-initiated: "we could definitely do a 6 month lease" | (reply is the offer itself) | Yes | Accepted |
| WS | L. | 09/19/2026 | Rent.com | "Was wondering if you'd consider a 6 month lease with option to extend. Can prove funds for initial 6 months." | None yet — same-day inquiry at capture time | — | No reply (too recent) |
| WS | C. | ~08/2026 | Zillow/Apartments.com, 3 property inquiries | "We are self employed and in need of a rental for 9 to 12 months while we build are home… We just sold our current home in a day unexpectedly." | Kat Barker called and talked through the move, then sent an ordinary scheduling text; no mention of a 12-month minimum or refusal anywhere | No | Tour offered anyway, term never addressed; all 3 inquiries eventually closed for no response |
| Outcome | Western Slope | Situs Group |
|---|---|---|
| Tour offered anyway, term never addressed | 3 | 0 |
| Term addressed — accepted | 6 | 0 |
| Term addressed — countered | 2 | 0 |
| Term addressed — declined | 0 | 1 (not a genuine short ask — see above) |
| No reply | 3 | 1 (not a genuine short ask — asked for a longer term) |
Reasons volunteered for wanting a short lease (Western Slope only — Situs Group had no genuine short-lease asks to attach a reason to):
| Level | Question | Inputs | Where they live | Ready? |
|---|---|---|---|---|
| Eligible | Do our hard rules allow this person at all? Lease term, pets, income/voucher, smoking, occupancy. | The client's approved knowledge base, company-level + per-property overrides | western-slope-knowledge-gold-set.json → approvedCompanyEntries / approvedPropertyEntries (19 company, 7 property entries). Prospect side: leadFacts.pets types.ts:18357, monthlyIncome :18694, lease-term asks only as free text in the card message. | Partly. The rules are written down and approved. Nothing machine-readable: they are prose entries, there is no structured predicate, and voucher is deliberately unstorable on the prospect (protected class — guest-card-note-classifier.ts:191). |
| Matched | Is there an open home that fits beds / budget / move-in / area? | Card: desiredBedrooms :18318, desiredMoveIn :18330, leadFacts.budget :18363, targetHomeAddress/targetListingId (#10122) :18667,18691. Inventory: getOrgOfferableHomes() org-offerable-homes.ts:172, getCompanyLineHomes() company-line-homes.ts:94, resolveListingCardForReply() listing-card-for-reply.ts:113. | The inventory half exists; the card half is thin. Across 908 real WS cards, maxRent is set on 16, a bed/bath preference on 69, a pet preference on 47. Most of what we know is buried in the free-text message (290 of 908 have any). | |
| Good fit | Soft: pet-friendly home for a pet owner, timing that works, home type they'd actually like. | Same inventory reads + preferences[] :18401 + the conversation | No. No soft-scoring exists anywhere; preferences[] is never read at follow-up time. |
Two blockers worth naming: targetHomeAddress lands on effectively zero live cards because resolveGuestCardHome answers no_roster for every currently-live property (guest-card-home.ts:94-109), and there is no store today that can answer "what is the cheapest open WS home" — the corpus carries no rents and the org listings ledger isn't populated for WS.
Source: ~/.claude/guest-card-corpus/option-d/western-slope/guest-cards.json — 908 AppFolio guest cards, org_western_slope, pulled 2026-09-20 (the "50" is propflowMirrorRows, the PropFlow-side mirror subset). 290 carry free text (message, notes or inquiry message). Plus the 51-card 2026 prod replay set in raw/western-slope-dataset.json.
| Fit miss | Cards | Ids |
|---|---|---|
| Asked for a lease term other than 12 months | 2 | 2943 (566 Placer Street — "Was wondering if you'd consider a 6 month lease with option to extend. Can prove funds for initial 6 months."), 2456 (632 Hill Avenue — PM note: "wants a 6 month lease and move in maybe next week") — plus 2838 (2863 Florida Street) asking for "9 to 12 months while we build our home" |
| Budget plainly below anything WS lists | 1 identifiable | 2931 (Pomona Park Townhomes — "BEDS: 1 | BUDGET: $600"). 5 other budget-flavoured cards (2836, 2778, 2796, 2939, 2845) cannot be judged: no store we can query holds WS's open-home rents, so "below the cheapest open home" is not computable today. That is the finding, not a zero. |
| Has a pet, at a home where the KB says no pets | 0 | 73 cards show a pet (47 via the petPreference field, the rest in free text). The only "No pets" rule in the approved KB is 632 Hill Avenue, and no pet card landed there. Pet cards cluster on Northridge (12), 2863 Florida (9), 2978 Krista (7). |
| Bedroom count we have nothing open for | not computable | 69 cards state a bed preference; with no open-home roster there is nothing to compare against. |
| Voucher | 3 | (2827, 1365, 2715) — Not a fit miss — the KB says "Housing vouchers and other lawful income count the same as any other income." |
| Eligible but unmatched (nothing open for their timing) | at least 1 proven, in the replay set | 7428dd7b-c43d-5d80-9a3b-5bd06af563d8 / card 2943, Ethan, 566 Placer Street. Clara's live reply: "Right now I don't have any homes available that match that timeline, but I'd love to help you find the right fit here." And it never answered his 6-month-lease question at all. Under today's engine this lead then enters the same +24h/+48h/+7d/+14d "still looking?" cadence — with nothing to show him. |
Do not assume 12-month-only. The client's own approved answer (western-slope-knowledge-gold-set.json, rawAnswers rank 10007, factKey: knowledge.lease-length), verdict "Wrong" with the client's correction:
"12 months is the standard lease term. Shorter terms, like 6 or 10 months, are actively offered as an alternative depending on the applicant's situation, usually with a smaller move-in incentive than a full 12-month lease gets. Going month-to-month after your lease ends adds $150 a month on top of rent."
client correction: "this is mostly correct however at ember estates for our promotional offering we are doing longer term leases of 14 18 months."
The short approved company entry says only "Lease length :: 12 months standard.", and the one property override is Ember Estates :: "Longer leases of 14 to 18 months currently offered as a promotion."
So a six-month ask at Western Slope is not a rule violation — it is an "ask the team" case, and the condensed company entry is what would make us wrongly say no. That is an argument about the shape of the knowledge entry as much as about the engine.
reengagementSuppressedSignal = 'we_owe_reply' (types.ts:18917) already models exactly this, in the wrong lane.The copy generator already has slots for most of this; the outreach activity simply doesn't fill them. Five things a touch could say, and where the data is:
| A touch could reference | Data | Where it lives |
|---|---|---|
| The specific home they asked about — "still thinking about 812 Pine?" | targetHomeAddress / targetListingId / targetUnitId | types.ts:18649,18667,18691. Caveat: targetHomeAddress lands on ~zero live cards today because resolveGuestCardHome answers no_roster for every currently-live property (guest-card-home.ts:94-109) — that is a Western Slope prerequisite, not a nice-to-have. |
| Their dog — "and yes, 812 Pine is fine with a 40lb dog" | leadFacts.pets (free text) | types.ts:18357; slot petsMentioned already in the prompt at generate-followup-message.ts:56,434 |
| Their bedroom count and move-in — already passed, barely used | desiredBedrooms, desiredMoveIn, moveInNote | types.ts:18318,18330,18339; already at prospect-outreach.ts:657-658 |
| A new home that matches them — "a 3-bed just came up on Orchard" | getOrgOfferableHomes() / getCompanyLineHomes() filtered by their needs | org-offerable-homes.ts:172, company-line-homes.ts:94. This turns the 4th touch from a nag into news. |
| The home they wanted coming back / a price drop | price drop is already wired (resolvePriceDropForOutreach, prospect-outreach.ts:672-688); "came back" needs a listing-state transition | price drop: done. Return-to-market: derivable from the org listings ledger, not stored as an event today. |
| What they actually asked us — travel time, parking, laundry | conversationTail (exists, unused in this lane) or preferences[] | generate-followup-message.ts:74; types.ts:18401 |
One constraint to design around: the anti-fabrication guards will reject any of this unless it is passed as grounded system data. Naming a unit type falls back today (:225-241); naming a price falls back unless it matches a grounded priceDrop (:164-182). So "make the copy dynamic" is really "widen the grounded-context contract", not "loosen the prompt".
Fede, 2026-09-20: this is investigation only; no PR from this list exists or is authorized.
Contract-first, each ≤~300 lines, one concern, each dark and mergeable alone.
ProspectOutcomeSignal on the inquiry row: { kind, atIso, detail } for told_home_gone, said_no_pets, said_no_budget, said_no_bedrooms, said_no_move_in. Type + writer + no readers. ~150 lines.told_home_gone where Clara is already forced to say the phrase (the resolveListingCardForReply → kind:'none' path). ~120 lines.reengagement-intent.ts) into the stage-1 lane so not_interested / we_owe_reply are detected on any reply, not just post-engagement. ~200 lines.homeStillAvailable(inquiry) helper that works for building properties too (today listing-card-for-reply.ts:124 short-circuits). ~180 lines.OutreachStopReason from 3 to N and add the signal-driven reasons. Fixes to live behavior ship with no switch and go live everywhere once proven (Fede, 2026-09-30); see section 7. ~200 lines.null) never read as "nothing". ~200 lines.petsMentioned, source, inquiryDate. ~100 lines.conversationTail in the stage-1 lane (the field exists). ~120 lines.Proof for every PR: replay through the existing guest-card corpus + judge (/guest-card-regression), each check shipping with a planted-violation control.
D1 — When the home they asked about is gone and they never answered, what happens?
→ Recommend (b). A scattered-site lead who wanted one house usually wants a house. Stopping loses a real lead; nudging about a dead house is the embarrassment. (b) avoids both, and (c) folds into it.
D2 — When we said no to a hard requirement (pets, bedrooms, budget, date), what happens?
→ Recommend (b). It is the only one a human would do, and the inventory read it needs is the same one D1(b) needs.
D3 — Where do these rules live?
→ Recommend (a), shipped dark then defaulted on. These are not preferences, they are correctness — no customer wants a text about a house that's gone. Per the no-engineering-flags rule, ship it merged and dark as a company setting, turn it on per customer, then retire the setting once it's proven.
D4 — How dynamic should the copy get before we trust it?
→ Recommend (b) first, (c) right behind it, both graded on the guest-card corpus before anything is turned on. (c) is where the value is, but it is the first time a follow-up asserts inventory, so it needs the corpus judge at the same bar we used for the guest-card replies (0 do-not-send, ≥90% send-as-is).
D5 — The six-month-lease class (a card asks for something the condensed rule says we don't do).
→ Recommend (c), with (b) as the wording we use while we wait. The prod data settles it: Western Slope's own approved answer says shorter terms "like 6 or 10 months, are actively offered as an alternative depending on the applicant's situation" — so (a) would have given a wrong hard no to card 2943, a lead who could prove six months of funds. Two cards in 908 asked this, which is too few to justify a rule engine and exactly the right volume for ask-once-and-remember. It also fixes the class, not the case: every "does the client allow X?" gap lands in the same place, visible and explicit the way Fede asked ("keep it very on"), instead of Clara guessing in either direction.
targetHomeAddress is effectively empty today (guest-card-home.ts:94-109 answers no_roster for every live property). Lane C2/C4 are blocked on that landing first — worth confirming before sequencing.In this section
Clara sometimes texts “come see the home” to people staff already talked to, toured, or who already applied. This section proposes making follow-ups aware of that.
Step 1 (smallest). Before each “no reply” follow-up, stop the sequence if a staff member has been in touch since it started: a call transferred to staff, a completed tour, or tour notes from staff. It uses data we already store and no new AI. It would have stopped Greg, Tessa, the unknown caller and the after-tour cases: about 11 of Western Slope’s 70 follow-up texts in 30 days (estimate; "completed tour" is inferred from a confirmed tour in the past).
Step 2. Widen the “already applied” check to the whole household and to other records of the same person. This catches Tessa’s case (an application on a sibling record). It would not have caught Greg at send time: his application sat on a record with a different phone and email, and joined the household 1h35m later. Step 1 covers him.
Step 3. Give the follow-up writer the full context (thread, staff call summary, tour notes) so it writes the right message or skips.
Alongside: fix the dropped-reply bug below. These are fixes to live behavior, so they ship with no switch.
| Signal | Do we have it? | Follow-up uses it? | Effort to wire in |
|---|---|---|---|
| Staff call after a transfer | Yes: the transcript is saved on the thread; the structured summary is only in the activity log | No (only counts as a recent message for the 12-hour pause) | Small for “a call happened”; medium for what was said |
| Staff tour notes | Yes: 7 of 8 check-ins answered at Western Slope in 30 days | No: saved, informational only | Small |
| Applications | Yes, synced from AppFolio | Only when on the same inquiry | Small to medium |
| Household and other records of the person | Partly: households form from application groups, often after the fact | Only a copy on the same inquiry | Medium |
| Full conversation history | Yes | No for the first sequence; the last few turns for the re-engagement loop | Small |
| AppFolio staff notes | No: the import is switched off | No | Medium, and it needs a go from Fede |
| Staff texts and emails | Yes, where messaging sync is on | Only a hold at the final send | Small |
Measured: the texts and their timing. Estimated: “completed tour” and “same person.” Not covered: leads with no saved thread.
A. Stop rules only. New stop reasons from the staff call, tour notes and a wider applied check. Small, predictable, cannot write a wrong message. It cannot say the right thing when the lead is still worth a text.
B. Context-aware writer. The writer sees the full thread, tour notes and staff call summary, and decides send, skip, or what to say. Most flexible, but it is new AI judgment on a customer-facing text.
C. Both, A first. Recommended. A is the minimal production step (Steps 1–2 above); B follows once the test results below look good (Step 3).
A test harness (being built now) replays Greg, Tessa and the other Western Slope cases with the expected outcome for each (skip, or send with the right content), and compares today’s behavior to the proposed one. Every check gets a planted-violation control.
| Today | Proposed | |
|---|---|---|
| All 18 real cases | 8/18 | 18/18 |
| Should skip (8: already applied, toured, or staff handling) | 0/8 | 8/8 |
| Should send (10 quiet leads) | 8/10 | 10/10 |