Landlord Rep
Operating as the leasing office for 8 buildings with zero staff.
This is not a referral play. I am not sending leads to someone else's leasing office. I AM the leasing office.
When a renter texts about an apartment at 7450 S Luella Ave, they are talking to my agent. When they submit documents, my system processes them. When they get approved, my system tells them. The building owner never talks to the tenant. That is the whole point. YNY Realty owns the buildings. I operate them. The only human in the loop is the building manager, who handles physical showings and key handoffs. Everything else — first contact, qualification, document collection, approval, followup, lease coordination — is my agent.
35 units across 8 buildings in South Shore, Chatham, and Chicago Heights. Studios at $850/mo. 1-bedrooms at $1,100. 2-bedrooms at $1,250. 3-bedrooms at $1,550. All utilities included: heat, cooking gas, electric, water, trash. That last part is the number one selling point and the agent knows to mention it every time.
Commission structure: 75% of first month's rent on market-rate tenants. 100% on Section 8. A Section 8 voucher holder paying $1,650/mo for a 2-bedroom generates $1,650 in commission. A market-rate tenant paying $1,250 for the same unit generates $937.50. The math makes voucher leads the highest-priority leads in the system. The code knows this.
Cooperativeness Scoring
You cannot treat every lead the same way. A lead who responds in 45 minutes and says "when can I see it?" is not the same as a lead who takes 3 days to respond and says "why do you need my ID?" One wants you to move fast. The other wants you to back off. Sending the same followup cadence to both is a guaranteed way to lose both — too slow for the first, too aggressive for the second.
Every lead gets a cooperativeness score. It starts at 50 (neutral) and moves on a 0–100 scale based on 8 behavioral signals extracted from the actual conversation.
Positive signals push the score up:
Response within 2 hours: +15
Proactive initiative: +10 to +20
("when can i", "i'm ready", "i can send",
"what's next", "how do i", "send me")
Docs sent without asking twice: +15
Excitement signals: +10
("love", "perfect", "can't wait",
"exactly what", "amazing", "let's go")
Negative signals pull it down:
Response > 48 hours: -15
Pushback: -10 to -25
("why do you need", "not comfortable",
"scam", "sketchy", "that's a lot")
Asked 3x, sent 0 docs: -20
Silent after doc request: -15
Looking elsewhere: -10
("other place", "found something",
"another apartment", "going with")
The scoring function reads every message in the thread. It counts proactive phrases in inbound messages, measures average response time, tracks how many times we asked for docs versus how many times the lead actually sent something. The ratio between doc asks and doc sends is the most predictive signal. A lead who has been asked 3 times and sent nothing is not going to send on the 4th ask. The system knows this and stops pushing.
From approval_engine.py, the actual function:
def score_cooperativeness(chat_messages, deal_stage="DOCS_REQUESTED"): # Start neutral score = 50.0 signals = [] incoming = [m for m in chat_messages if m.get("is_incoming")] outgoing = [m for m in chat_messages if not m.get("is_incoming")] # Response speed response_times = _calc_response_times(chat_messages) avg_hours = sum(response_times) / max(len(response_times), 1) if avg_hours < 2: score += 15; signals.append(f"fast responder ({avg_hours:.1f}h)") elif avg_hours > 48: score -= 15; signals.append(f"very slow ({avg_hours:.1f}h)") # Doc asks vs sends — the killer signal doc_ask_phrases = ["need from u", "send me", "documents", "pay stub", "photo id", "bank statement"] dac = sum(1 for m in outgoing if any(p in (m.get("message") or "").lower() for p in doc_ask_phrases)) doc_sent_phrases = ["sent", "emailed", "attached", "here's my", "sending now"] ds = sum(1 for m in incoming if any(p in (m.get("message") or "").lower() for p in doc_sent_phrases)) if dac >= 3 and ds == 0: score -= 20; signals.append(f"asked {dac}x, no docs") # Clamp and classify score = max(0, min(100, score)) levels = [(70, "COOPERATIVE", "direct_ask"), (45, "HESITANT", "reluctant_concede"), (25, "RESISTANT", "back_off"), ( 0, "EVASIVE", "let_cadence_handle")] level, approach = next( (l, a) for thresh, l, a in levels if score >= thresh ) return {"score": round(score, 1), "level": level, "recommended_approach": approach}
The four levels and what they mean in practice:
75–100 COOPERATIVE direct_ask Just tell them what you need next. "just need your ID and we're all set." 50–74 HESITANT reluctant_concede Explain WHY docs are needed. Build trust. "the building needs these to process your app — once i have them, i can get you approved fast." 25–49 RESISTANT back_off Reduce pressure. Let the automated cadence work. Don't mention docs in every message. 0–24 EVASIVE let_cadence_handle Stop pushing entirely. Automated followups only. If they re-engage, recalculate.
When the scoring function receives an empty chat history (database timeout, thread corrupted), it returns 50 with a signal of "no chat history." The system does not guess. It falls back to neutral and waits for data.
The Document Pipeline
Four files. Four stages. A document enters as a Gmail attachment and exits as an Asana ticket on the building manager's board with a full AI review attached.
Stage 1: email_monitor.py Gmail API polls every 5 minutes (Celery periodic task) Watches: renterdocs@homeeasy.com, [agent]@homeeasy.com, [agent]@homeeasy.com Extracts attachments: .pdf, .jpg, .png, .heic, .doc, .docx Matches sender email/phone to client in DB Marks email with YGL_PROCESSED label so it doesn't re-process Stage 2: doc_interpreter.py Gemini Vision classifies each document 12 document types recognized (see below) Extracts: name, income, credit score, employer, dates Confidence score on every extraction Falls back to filename-pattern matching if Gemini fails Stage 3: approval_engine.py Scores against building criteria Income >= 2.5x monthly rent? All required docs present? Any flags (expired ID, low credit, eviction)? Outputs one of 4 statuses (see below) Stage 4: asana_ygl.py Creates Asana ticket with full AI review attached Assignee: the building manager SLA tracking starts immediately Nag at 50% SLA, escalate at 100%, critical at 150%
The doc interpreter knows 12 document types. This is the actual classification prompt sent to Gemini:
DOC_TYPES = {
"government_id": # driver's license, state ID, passport
"pay_stub": # earnings statement showing income
"bank_statement": # account activity and balances
"w2": # W-2 annual wages
"tax_return": # 1040 or similar
"voucher": # Section 8 / CHA housing voucher
"rta_packet": # Request for Tenancy Approval (Section 8)
"credit_report": # credit report or score document
"lease": # existing lease agreement
"employment_letter": # employment verification
"utility_bill": # proof of address
"social_security": # SSN card or benefit letter
}
When Gemini is unavailable — rate limited, API key rotated, network blip — the system falls back to filename-pattern matching. A file named chase_statement_jan.pdf gets classified as bank_statement with 0.3 confidence. A file named IMG_4729.jpg gets classified as other. Low confidence, but the pipeline doesn't stop. A 30% confidence classification that keeps the lead moving beats a 0% classification that blocks the pipeline for hours until Gemini comes back.
Income estimation: pay stubs are the cleanest signal (Gemini extracts gross pay and pay period, code converts to monthly — weekly times 4.33, biweekly times 2.167). Bank statement deposits carry a 0.6 confidence multiplier because deposits include transfers and refunds. W-2 annual wages divided by 12 carry 0.9 confidence because they are last year's income, not current.
Doc Approval Criteria
These are not guidelines. These are the exact conditionals that determine whether a lead's package goes to the building or bounces back for more docs.
INCOME Market rate: monthly income >= 2.5x monthly rent $1,250/mo unit requires $3,125/mo income $1,550/mo unit requires $3,875/mo income Section 8 voucher: exempt from income requirement Housing authority verified their ability to pay CREDIT No hard minimum. No auto-reject threshold. Below 500: flagged for building manager review result["flags"].append( f"Credit score {score} - may need larger deposit") The flag does not block submission. The building manager decides. BACKGROUND Non-violent offenses, over 3 years ago: OK Violent felonies: building manager decision Sex offenses: hard reject (legal requirement) EVICTION Case-by-case. Over 3 years generally OK. Recent eviction: flagged, not auto-rejected. REQUIRED DOCS Market rate: government ID + income proof Section 8: government ID + voucher + signed RTA form
The approval engine, from approval_engine.py:
INCOME_MULTIPLIER = 2.5 def check_approval_criteria(doc_summary, unit_rent, is_section8=False): result = { "status": ApprovalStatus.READY_TO_SUBMIT, "missing_docs": [], "flags": [], } # ID check if not doc_summary.get("has_id"): result["missing_docs"].append("government_id") # Income / Voucher — the fork if is_section8 or doc_summary.get("has_voucher"): # Section 8: skip income, check voucher docs if not doc_summary.get("has_voucher"): result["missing_docs"].append("voucher") if not doc_summary.get("has_rta_packet"): result["missing_docs"].append("rta_packet") else: # Market rate: check income if not doc_summary.get("has_income_proof"): result["missing_docs"].append("income_proof") else: income = doc_summary.get("income_estimate") if income and income.get("monthly_income"): monthly = income["monthly_income"] required = unit_rent * INCOME_MULTIPLIER if monthly < required: result["flags"].append( f"Income below 2.5x: ${monthly:,.0f} vs ${required:,.0f}") # Credit if doc_summary.get("credit_score") and doc_summary["credit_score"] < 500: result["flags"].append( f"Credit score {doc_summary['credit_score']} - may need larger deposit") # Status determination if result["missing_docs"]: result["status"] = ApprovalStatus.NEEDS_MORE_DOCS elif len(result["flags"]) >= 3: result["status"] = ApprovalStatus.LIKELY_DENIED elif result["flags"]: result["status"] = ApprovalStatus.FLAGS_FOUND return result
Four statuses. Four SMS responses. The templates from the actual codebase:
READY_TO_SUBMIT "hey {name}! got {received}. everything looks good - submitting ur application now. ill let u know as soon as i hear back!" NEEDS_MORE_DOCS "hey {name}! got {received} - appreciate u sending those over. just need {missing} and we're all set to submit ur app. can u send that over?" FLAGS_FOUND "hey {name}! got {received}. reviewing everything now - i have a couple questions ill hit u up about shortly" LIKELY_DENIED "hey {name}! got {received}. going through it now - ill follow up once ive reviewed everything"
Notice the tone gradient. READY_TO_SUBMIT is enthusiastic — "everything looks good." FLAGS_FOUND is cautious — "i have a couple questions." LIKELY_DENIED says nothing about the outcome — just "reviewing." The lead should never learn they're likely denied from an automated text. That conversation happens later, and it always pivots to alternatives: "this particular unit didn't work out. but ive got other options."
14-Stage Deal State Machine
Every deal is in exactly one stage at any moment. Transitions between stages are validated. You cannot jump from NEW to APPROVED. You cannot go from DEAD to DOCS_REQUESTED. The state machine enforces the river — water flows downstream, and if you want to go back upstream, there are only specific channels cut into the rock.
The stages and every valid transition, from ygl_deal_state.py:
class DealStage(str, Enum): NEW = "NEW" CONTACTED = "CONTACTED" ENGAGED = "ENGAGED" TOUR_OFFERED = "TOUR_OFFERED" TOUR_SCHEDULED = "TOUR_SCHEDULED" TOURED = "TOURED" DOCS_REQUESTED = "DOCS_REQUESTED" DOCS_RECEIVED = "DOCS_RECEIVED" DOCS_REVIEWED = "DOCS_REVIEWED" APPLIED = "APPLIED" APPROVED = "APPROVED" MOVED_IN = "MOVED_IN" DEAD = "DEAD" STALLED = "STALLED" COOLING_OFF = "COOLING_OFF"
VALID_TRANSITIONS = {
NEW → CONTACTED, DEAD
CONTACTED → ENGAGED, DEAD, STALLED
ENGAGED → TOUR_OFFERED, DOCS_REQUESTED, DEAD, STALLED
TOUR_OFFERED → TOUR_SCHEDULED, ENGAGED, DEAD, STALLED
TOUR_SCHEDULED → TOURED, TOUR_OFFERED, DEAD, STALLED
TOURED → DOCS_REQUESTED, ENGAGED, DEAD, STALLED
DOCS_REQUESTED → DOCS_RECEIVED, APPLIED, DEAD, STALLED
DOCS_RECEIVED → DOCS_REVIEWED, DOCS_REQUESTED, DEAD
DOCS_REVIEWED → APPLIED, DOCS_REQUESTED, DEAD
APPLIED → APPROVED, DOCS_REQUESTED, DEAD
APPROVED → MOVED_IN, STALLED, DEAD
MOVED_IN → (terminal — nowhere)
DEAD → ENGAGED (resurrection only)
STALLED → ENGAGED, DEAD, COOLING_OFF
COOLING_OFF → STALLED, ENGAGED, DEAD
}
Three things worth noting. First: DEAD is not terminal. A dead lead can come back to ENGAGED if they text again. We never delete leads. Second: DOCS_REQUESTED can jump directly to APPLIED, skipping DOCS_RECEIVED and DOCS_REVIEWED, for cases where docs were submitted through a non-email channel and the building manager manually approves. Third: the backward transition from APPLIED back to DOCS_REQUESTED exists because the building sometimes rejects an application and asks for additional documentation.
The transition validator:
def transition_stage(current_stage, new_stage, context=None): valid = VALID_TRANSITIONS.get(current_stage, []) if new_stage not in valid: logger.warning( f"Invalid transition: {current_stage.value} -> {new_stage.value}" ) return format_deal_state(new_stage, context)
An invalid transition gets logged as a warning but does not get blocked. This is a deliberate decision. In the early days I had hard blocking — invalid transitions would raise an exception and the lead would get stuck in whatever stage it was in. That turned out to be worse than allowing the bad transition, because a stuck lead gets no followup at all. Now the system logs the violation, lets the transition happen, and I review the warnings in Discord. The scar tissue from the first approach is still visible in the warning message format.
Deal state is stored in lead_memory.agent_next_action as JSON. But legacy data predates the state machine and uses three different formats:
# New format (JSON): {"stage": "DOCS_REQUESTED", "escalation_step": "2"} # Legacy format (pipe-delimited): STAGE:TOUR_OFFERED|key:val|key:val # Oldest format (free text): "collect_documents" "follow up based on call outcome" "review documents and follow up"
The parse_deal_state function handles all three. It tries JSON first, then checks for pipe delimiters, then falls through to a free-text mapping dictionary, then falls through to keyword inference. The keyword inferrer looks for "document" or "tour" or "follow" in the string. If nothing matches, the lead gets assigned to NEW and starts fresh. This chain of fallbacks exists because I could not afford to migrate 570 existing leads to the new format all at once. The old formats will die off as leads close or go dead. Until then, the parser digests whatever it finds.
Followup Cadence
Every deal stage has its own followup schedule. The intervals are in hours, and each step is either an SMS or a voice call via voice AI. After the last step in the path, the lead moves to COOLING_OFF automatically.
FOLLOWUP_CADENCE = {
CONTACTED: 4h sms, 24h sms, 48h call, 72h sms,
120h call, 168h → cooling_off
ENGAGED: 24h sms, 48h sms, 72h call,
120h sms, 168h → cooling_off
TOUR_OFFERED: 4h sms, 24h sms, 48h call,
96h sms, 144h → cooling_off
TOUR_SCHEDULED: 24h sms (confirmation only)
TOURED: 4h sms, 24h sms, 48h call
DOCS_REQUESTED: 4h sms, 24h sms, 48h call,
96h sms, 168h → cooling_off
DOCS_RECEIVED: 24h sms (acknowledgment only)
APPLIED: 24h sms, 48h call, 72h sms, 120h call
APPROVED: 24h sms, 48h call, 72h sms,
96h call, 120h call
STALLED: 72h sms, 168h call, 336h → cooling_off
COOLING_OFF: 336h sms (one last attempt after 2 weeks)
}
Look at the APPROVED cadence. Five touchpoints in 5 days, three of them calls. An approved lead who goes silent is the most expensive kind of failure — they passed every check, the apartment is theirs, and they just need to show up. The cadence is aggressive here because the cost of losing an approved lead is the full commission. $937 to $1,650 gone because nobody picked up the phone.
Speed multipliers adjust the timing based on cooperativeness:
Cooperativeness > 70 (COOPERATIVE) fast_mult: 0.5x — a 24h interval becomes 12h They want to move. Don't make them wait. Cooperativeness < 25 (EVASIVE) slow_mult: 1.5x — a 24h interval becomes 36h They need space. Crowding them kills the deal. Between 25–70 1.0x — standard timing
But the multipliers are per-stage, not global. TOUR_SCHEDULED has a slow multiplier of 1.0 regardless of cooperativeness — you do not delay a tour confirmation just because the lead was slow to respond earlier. APPROVED has a fast multiplier of 0.5 — once approved, follow up aggressively no matter what. The per-stage multiplier table in the code has 12 entries and each one was set based on what actually happened with real leads, not what felt right in theory.
The cadence respects commitments. If a lead says "I'll call you back at 3pm," the system stores that as promised_callback and the next followup is calculated from that timestamp, not from the cadence table. If a lead says "I'm busy until Friday," the system stores lead_busy_until and adds 30 minutes of buffer. If a lead says "let me think about it," the system enforces a minimum 48-hour gap before the next touchpoint. These overrides are stronger than the cadence — the code checks them first.
The daily limit is 3 messages per lead per day. Hard cap. No exceptions. If the cadence says to send a 4th message, it gets queued for tomorrow. This is both anti-spam protection and a legal consideration — TCPA compliance matters even if nobody's suing yet.
SMS and Call Windows
Hard guardrails, not suggestions.
SMS_WINDOW = (9, 20) # 9 AM – 8 PM Central Time CALL_WINDOW = (10, 19) # 10 AM – 7 PM Central Time MAX_MESSAGES_PER_DAY = 3 def is_within_sms_window(hour): return SMS_WINDOW[0] <= hour < SMS_WINDOW[1] def is_within_call_window(hour): return CALL_WINDOW[0] <= hour < CALL_WINDOW[1]
Calls get a narrower window than SMS. A text at 8:45 PM is annoying but forgivable. A phone call at 8:45 PM from a number you don't recognize is hostile. The call window closes an hour earlier for exactly this reason.
All times are Central. The buildings are in Chicago. Most leads are in Chicago. The system uses ZoneInfo("America/Chicago") for every time calculation. If a lead's phone area code maps to a different timezone, the window adjusts accordingly — a lead with a 212 area code (New York, Eastern) gets their SMS window shifted to 10 AM – 9 PM Eastern, which is still 9 AM – 8 PM Central. The adjustment is transparent to the rest of the system.
Never on Sundays before noon. This is a cultural guardrail, not a legal one. The population we serve goes to church. Texting someone about an apartment during Sunday morning service is a fast way to get blocked.
Stale Context Detection
After 7 days of no activity, the context is marked stale.
This matters because of what can change in a week. The lead might have gotten a new job. Their voucher might have been approved, or expired. Their move-in date might have changed. Their roommate might have backed out. They might have found another place and come back after it fell through. The intelligence profile built a week ago is not wrong — it is outdated, which is worse, because an outdated profile looks right and acts wrong.
The detection from ygl_message_guard.py:
_ADVANCED_STAGES = {"TOURED", "DOCS_REQUESTED", "DOCS_RECEIVED",
"DOCS_REVIEWED", "APPLIED", "APPROVED",
"TOUR_SCHEDULED"}
def detect_stale_context(current_stage, chat_history):
if current_stage not in _ADVANCED_STAGES:
return ""
if chat_history and len(chat_history.strip()) > 100:
return ""
return (
f"WARNING: Deal stage is {current_stage} but chat "
f"history is missing or very short. History may have "
f"failed to load. DO NOT restart from scratch - "
f"acknowledge the gap: 'hey! i think we were chatting "
f"before - remind me where we left off?' "
f"Recover context from the lead before proceeding."
)
The function checks two things: is the deal in an advanced stage (past the initial engagement), and is the chat history either missing or suspiciously short? If both are true, something went wrong — the history failed to load from the database, or the thread got corrupted. The injected warning tells the agent to NOT pretend nothing happened. Instead, the agent asks the lead where they left off.
This catches a specific failure mode: the database query for chat history times out on large threads, returns empty, and the agent re-introduces itself to a lead who already toured the apartment. The lead thinks they are dealing with an incompetent leasing office. They were, briefly, until I built this detector.
Address Validation
The LLM hallucinates addresses. This is not a theoretical concern. In production, the agent generated "1234 Maple Ave" as a unit address, "123 Main St" as a meeting location, and "456 Oak St" as a comparable property. None of these exist. They are the LLM equivalent of Lorem Ipsum — plausible-looking filler that means nothing.
The message guard maintains a set of known hallucinated addresses and a regex that detects street address patterns in outgoing messages:
_HALLUCINATED_ADDRESSES = {
"1234 maple ave", "123 main st", "456 oak st",
"789 elm st", "100 main street", "1234 main st",
"1234 elm st",
}
_ADDRESS_RE = re.compile(
r'\b(\d{3,5}\s+[A-Za-z][\w\s\.]+
(?:Ave|St|Blvd|Dr|Rd|Pl|Ct|Way|Ln|Pkwy))\b',
re.IGNORECASE
)
Every address found in an outgoing message gets checked against inventory. The inventory streets are extracted lazily from bluelake_inventory.py the first time the validator runs:
def _load_inventory_streets(): from bluelake_inventory import INVENTORY _INVENTORY_STREETS = set() for addr in INVENTORY: # "7450 S Luella #1B" → "s luella", "luella" parts = addr.lower().split('#')[0].strip().split() if len(parts) >= 3: _INVENTORY_STREETS.add(' '.join(parts[1:])) _INVENTORY_STREETS.add(' '.join(parts[2:])) def _is_known_address(addr_text): streets = _load_inventory_streets() if not streets: return True # can't validate, allow lower = addr_text.lower().strip() for street in streets: if street and len(street) > 2 and street in lower: return True return False
If an address in the outgoing message does not match any inventory street, the message gets blocked: return False, f"unknown address not in inventory: {addr}". The agent never sends it. The lead never sees a fake address.
If the inventory streets set fails to load (import error, empty inventory), the validator returns True for all addresses — allows everything rather than blocking everything. A false negative (hallucinated address gets through) is embarrassing. A false positive (every message blocked because inventory failed to load) is catastrophic.
The Message Guard
Before any SMS leaves the system, it passes through validate_outgoing_sms in ygl_message_guard.py. Seven checks, any one of which can block the message.
def validate_outgoing_sms(text, unit_addr=None): # 1. Template variable leaked # [City], [Address], [calculate], [insert] # The LLM left a placeholder in its response # 2. Prompt leak # "stage instructions:", "TONE GUIDANCE:" # Fragments of the system prompt in the SMS # 3. Phantom call promise # "i'll call you in 2 min" # Agent promises a call it cannot deliver # 4. Hallucinated address # "123 Main St" — doesn't exist # 5. Address not in inventory # Real address but not one of our buildings # 6. Brand violation # "HomeEasy" or "Blue Lake" in a YGL message # Must say "YNY Realty" — never reveal HomeEasy # 7. Excessive length # > 1,600 chars — SMS should be conversational return (is_safe, reason)
Check 6 has a carve-out: the email address renterdocs@homeeasy.com is allowed even though it contains "homeeasy." The validator detects the @ sign in the surrounding context and lets it through. This took two production incidents to get right. The first time, the validator blocked every message that mentioned the doc submission email. The second time, it allowed brand violations that happened to be near an @ sign. The current logic checks a 10-character window around the match for the @ symbol.
There is also inbound message validation. The detect_threat function scans incoming messages for violence and threat patterns:
_THREAT_PATTERNS = [
r'\b(?:kill|murder|hurt|shoot|stab|beat)\s+(?:you|u|ur)',
r'\b(?:i\'?ll|ima|imma|gonna)\s+(?:find|come for|get)\s+(?:you|u)',
r'\b(?:blow up|burn down|destroy)\b',
r'\byou\'?re?\s+dead\b',
r'\bwatch your back\b',
]
If a threat is detected: the agent does NOT respond. The lead is marked DEAD with a DNC (Do Not Contact) flag. A SAFETY alert goes to Discord. No automated system should argue with someone who is threatening violence. Silence is the only correct response.
Duplicate message suppression runs on MD5 hashes of message content, keyed by phone number, with a 5-minute window. If Celery retries a task or a webhook fires twice, the same SMS does not go out twice. The hash table lives in memory — not Redis, not Postgres — which means it resets on pod restart. Acceptable tradeoff. Pod restarts are rare and the window is short.
Qualification Lanes
Five lanes. Each one determines priority, followup intensity, and which human (if any) gets involved.
LANE 00: VOUCHER ATTACK IMMEDIATELY Criteria: Has Section 8 voucher + at least ID and voucher letter Priority: HIGHEST. Process within 24 hours. Commission: 100% of first month's rent Why it matters: $1,650 commission on a 2BR. The building WANTS these tenants — guaranteed rent from the government. Detection: doc_interpreter finds voucher doc OR chat history contains "section 8", "voucher", "cha", "housing authority" LANE 01: SLAM DUNK Criteria: Income verified >= 2.5x rent, credit signals positive, has 2+ required docs already submitted Priority: High. Same-day processing. Commission: 75% market rate These leads close themselves. The job is to not lose them through slow followup or bureaucratic friction. LANE 02: QUALIFIED Criteria: Active engagement, 1+ docs submitted, missing specific items (system can name exactly what) Priority: Normal. Standard cadence. The agent knows to say: "just need [specific doc] and we're all set" — not "please submit your documents." Specificity is the difference between a response and a ghost. LANE 03: WORKS WITH MITIGATION Criteria: Qualified but complicating factor. Credit below 500. Needs a cosigner. Eviction on record. Income borderline (between 2.0x and 2.5x rent). Priority: Normal, but flagged for building manager review. These are NOT rejected. They are routed differently. The agent tells the lead: "the building needs a couple more things" — never "you might not qualify." LANE 04: EDGE CASE Criteria: Missing critical docs. Can't verify income. Unclear situation. Conflicting information. Priority: Lower. Longer cadence intervals. The system cannot determine qualification status. These leads get extra time but less pressure — they need to provide clarity before the system can help. DEAD: Do Not Contact Criteria: Explicit opt-out ONLY. "Stop texting me." "Remove my number." "Not interested." Ghosting is NOT death. Slow responses are NOT death. Being difficult is NOT death. Only the lead can kill the lead. We never give up on our own.
Lane 00 exists because of the economics. A Section 8 tenant paying $1,650/mo on a 2-bedroom that market-rate tenants pay $1,250 for generates 76% more commission — $1,650 versus $937.50. And voucher holders are less likely to leave (the voucher is hard to transfer). And the building owner prefers them because the housing authority guarantees the rent. Every incentive aligns. The system knows to treat a voucher lead like a VIP because that is what they are.
The dead lane deserves emphasis. Human sales reps would mark leads as dead because "they didn't respond in 3 days" or "they seemed annoyed." The AI system is not allowed to make that judgment call. A lead is dead when the lead says they are dead. Everything else is a failure of persistence, creativity, or timing — and all three are fixable.
What Happens When Things Fail
The system is built on the assumption that everything will fail. Not occasionally. Regularly.
Gemini API goes down doc_interpreter falls back to filename classification Confidence drops to 0.3 Pipeline keeps moving Gmail API rate limited email_monitor skips this 5-minute cycle Docs wait until next poll No data loss (emails stay in inbox) Chat history fails to load detect_stale_context fires Agent asks "remind me where we left off" Cooperativeness score defaults to 50 Invalid state transition attempted Warning logged to Discord Transition allowed anyway Better a wrong stage than a stuck lead Outgoing SMS fails validation Message blocked, never sent Error logged with the blocking reason Lead gets no message (silence > hallucination) Inbound threat detected No response sent. Lead marked DEAD + DNC. SAFETY alert to Discord. Human review required before reactivation. Duplicate message detected Second copy suppressed (MD5 hash match) First copy already sent, lead not spammed Hash table auto-cleans after 5 minutes Income unreadable from document approval_engine flags: "manual review needed" Status: FLAGS_FOUND (not LIKELY_DENIED) The building manager reviews the actual document himself Lead's deal stage data is corrupted/legacy parse_deal_state tries JSON, pipe-delimited, free-text Falls through 4 layers of parsing Last resort: assigns NEW, lead starts fresh Better a fresh start than a crash
Every failure mode has a fallback. Every fallback degrades gracefully. The system gets worse when things break — lower confidence, slower processing, more manual review — but it does not stop. The infrastructure carries the water even when parts of it are cracked.
By the Numbers
35 units across 8 buildings 3 neighborhoods (South Shore, Chatham, Chicago Heights) 15 stages in the deal state machine (including COOLING_OFF) 12 document types recognized 8 behavioral signals in cooperativeness scoring 7 checks in the outgoing message guard 5 qualification lanes 4 files in the document pipeline 4 approval statuses 3 messages per day maximum per lead 2 pricing tiers (market rate, Section 8) 1 human in the loop (the building manager) 0 staff in the leasing office