0104 — Clara knows a property's floor-plan media (3D tours + drawings) and can send the right one
- Status: Proposed
- Date: 2026-07-20
- Deciders: Fede
Context
Camellia now has five Matterport 3D walkthroughs (one per scanned floor plan) and a set of
direct, prospect-facing tour pages on the marketing site (camelliadenver.com/tours/<plan>,
built on camellia-site PR #12 — held pending CoStar/Apartments.com media-reuse approval).
Prospects regularly ask Clara for "pictures", "a video", or "a virtual tour" on every
channel, and callers who can't visit in person have no self-serve way to see a unit.
Today Clara cannot help with any of this:
- No storage.
PropertyKnowledge.tourUrlexists but is orphaned — not in the admin UI, not in the knowledge PATCH allow-list (src/app/api/properties/[id]/knowledge/route.ts), and read by no prompt. There is no per-floor-plan media structure. - Wrong prompt concept.
clara-leasing.ts§VIRTUAL TOURS defines a "virtual tour" as a PM-hosted live video call and says verbatim "There is no Zoom / Teams link to share up front" — the opposite of a self-guided walkthrough link. - No voice injection. The call-start leasing context (
leasing-context-injection.ts→ personalization webhook dynamic variables) has no tour field, so voice Clara doesn't know tours exist. - No send path from a call.
send_application_linkis text-only. The only voice tools that text links (send_portal_link_sms,send_offer_sms) are renewal tools that resolve the phone from the tenant record, not the live caller.
Cross-property check (§5): Yale 25 Station's site has NO 3D tours (its "3D Tour" nav link 404s) but does have public per-plan floor-plan drawings — so the design must degrade per-column, not assume Matterport.
Trello: https://trello.com/c/yxEVcK0B (Matterport integration) and https://trello.com/c/kox84jG0 (stale apartments.com listing, related surface).
Decision
Give every channel one property-level source of truth for tour links, and give Clara one guarded way to resolve "which tour?" so she never types a URL from memory.
1. Storage: PropertyKnowledge.floorPlanMedia (multi-tenant, per floor plan)
Add an optional per-floor-plan media array on PropertyKnowledge:
floorPlanMedia?: {
label: string; // PM-facing + prospect-facing name, e.g. "Two Bedroom L"
bedrooms: number; // 0 = studio — the matching key
bathrooms?: number;
sqft?: number;
tourUrl?: string; // self-guided 3D tour — for Camellia, the branded site page
// (https://www.camelliadenver.com/tours/two-bedroom-l),
// NOT the raw my.matterport.com URL
floorPlanUrl?: string; // the floor-plan drawing (public image URL) — MMS-able
}[];
One row per floor plan, carrying whichever media exist. "Can you send me the floor plan?" and "can I get a virtual tour?" are the same lookup with a different column.
- Editable in the property-detail knowledge UI; added to the PATCH allow-list.
- The orphaned property-level
tourUrlfield is removed in the same change (migrated intofloorPlanMediawhere set, then deleted from the type). - We store the marketing-site page URL, not the raw Matterport embed: the branded page carries Apply/Inquire CTAs, unfurls with a real interior photo in iMessage/WhatsApp, and lets us swap the underlying scan (e.g. if CoStar approval changes) without breaking links already sent to prospects.
2. Context: how Clara knows which tour to send
The match key is unit type / floor plan under discussion, which every channel already tracks:
- SMS/email: the leasing prompt receives the
floorPlanMedialist (label + bedrooms + sqft + url). Clara matches against the conversation's unit of interest (the prospect's saved bedroom preference fromsave_prospect/update_prospect, or the unit being discussed). Ambiguous → she asks ("studio, one-bedroom, or two-bedroom?"). No match for the requested type → she sends the tours index page (/tours) and says which plans have walkthroughs. - Voice: a new
floor_plan_mediadynamic variable in the call-start injection (personalization webhook), rendered from the same field — following the amenities/neighborhood/utilities injection pattern. Empty field → empty string → fail-closed: voice Clara simply doesn't offer tours for that property. - The prompt's §VIRTUAL TOURS section is rewritten to distinguish self-guided 3D tour links (shareable, this ADR) from live video tours with a PM (bookable, existing behavior). Asking for "photos/video/virtual tour" triggers the link offer; Clara always follows with the in-person/live tour offer — the link feeds the funnel, it does not replace the tour booking flow.
3. Sending: one resolver tool, channel-appropriate delivery
New tool get_floor_plan_media({ bedrooms, media }) (text channels), media ∈
virtual_tour | floor_plan — returns the exact URL + label from PropertyKnowledge,
which Clara includes inline in her reply (same inline pattern as the PROPERTY INFO
application-link bullet — D1-A, 2026-08-19, clara-unified.ts). Tool-resolved URLs mean
Clara never reconstructs a link from memory — a hallucinated URL to a prospect is worse
than no link.
New voice tool send_floor_plan_media({ bedrooms, media }) — covers both
"text me the virtual tour" and "can you send me the floor plan?":
- Resolves the caller's phone from the live call context (
resolveVoiceWebhookContext, the same plumbing the tools route already uses). - Resolves the URL (or floor-plan image) from
PropertyKnowledge.floorPlanMedia. - Sends ONE message through the standard consent-gated path — an SMS with the tour
link, or an MMS with the floor-plan drawing attached (
sendMms) formedia: floor_plan(SMS-with-link fallback if MMS fails) — then returns success/failure so Clara can confirm aloud ("Just texted it to you") or gracefully offer email instead.
Delivery timing: the send fires immediately mid-call when allowed, so the caller has
it before hanging up. When it can't fire mid-call (quiet hours, transient carrier
failure), it queues for post-call delivery via the existing call-ended hook — the one
place that already texts the live caller's phone (call-ended/route.ts recap SMS) — or
for the next quiet-hours window, and Clara sets that expectation aloud.
Message body is fixed-template and strictly informational: the plan label + the link (or image). No specials, no promotional copy — promo content moves the message to the prior-express-written-consent tier (TCPA KB, researched 2026-06-18).
Guardrails on the send:
- Clara must have offered and the caller accepted ("want me to text it to you?") — oral consent on a caller-initiated call satisfies prior express consent for an informational text; the call recording/transcript is the consent record.
- Suppression/opt-out check fails closed for this category.
- Quiet hours (8am–9pm recipient-local, stricter-state aware): the SMS path has no quiet-hours enforcement today, so this tool carries its own check; outside the window Clara says she'll text it in the morning (the send is queued, not dropped).
- Per-call idempotency: one send per (call, plan, media type) — no re-sends on tool retries.
4. Explicitly out of scope
- Autonomous/outbound tour blasts (cadence touches). This ADR covers requested sends in an active conversation only. No new send-arm is introduced (hot-rule #13: no arms by default) — the human-in-the-loop is the prospect asking.
- Camellia media licensing. The site pages ship only after CoStar/Apartments.com approval; this ADR's plumbing is property-agnostic and works for any property's tour links (owned scans, YouTube walkthroughs, etc.).
5. Cross-property validation: Yale 25 Station (second live data point)
Checked 2026-07-20 against our second prod property (PROP#1773625952029,
yale25stationapartments.com, Apartments247 platform):
- No 3D tours exist there — the site's own "3D Tour" nav link 404s. The optional-column
design handles this correctly: Yale rows carry only
floorPlanUrl,tourUrlstays empty, and Clara offers the floor-plan drawing instead of a walkthrough (never a dead tour offer). Fail-closed per column, not per property. - Floor-plan drawings are public, per-plan, and self-describing —
media.apts247.info/.../01A_-_574_SF_*.png,11A - 775 SF,22J - 1161 SF, where the digits encode beds/baths. Directly MMS-able; bedrooms/sqft parseable at import time. - A third media kind exists: a SightMap interactive availability map
(
sightmap.com/embed/...) — property-level, not per-plan. Covered by one optional property-level companion field rather than forcing it into per-plan rows:
mediaPages?: {
toursIndexUrl?: string; // e.g. camelliadenver.com/tours
unitMapUrl?: string; // e.g. SightMap embed/page
galleryUrl?: string; // photo gallery page
};
Gaps this exposes in today's config/knowledge base (why this ADR adds fields rather than reusing something):
PropertyKnowledgehas no per-floor-plan structure of ANY kind (Yale's live row: amenities/sections/pricing/neighborhood only) — floor plan names, sqft, and images exist nowhere in PropFlow, only on each property's website.tourUrlis NULL on every checked property and unreachable by prompts/UI (orphaned).- The website knowledge scraper (
scrapers/shared.ts) doesn't collect floor-plan or media data, sofloorPlanMediastarts manual-entry-only. Follow-up (phase 2 of implementation): teach the scraper to proposefloorPlanMediarows from the property site (plan name / sqft / image URL are machine-readable on both platforms we checked — camellia-site and Apartments247). Scraper proposes, PM confirms in the knowledge UI; the manual path ships first and is the override.
Consequences
- One more knowledge field to keep current per property — acceptable: tour links change ~never, and the admin UI edit is self-serve.
- Voice Clara's injection grows by one small static field; no new cache machinery needed
(the list is tiny and changes rarely; if it later rides
VoiceInjectionCache, that's an implementation detail, not a contract change). - The prompt's tour language must be updated on BOTH text and voice templates in the same release, or the two channels will describe tours inconsistently.
- New eval cases required: "can I see pictures?" (each channel), "can you send me the floor plan?" (voice → MMS), "which tours do you have?", ambiguous unit type, no-media property (voice must not offer), quiet-hours refusal → post-call queue, opted-out caller.
- Follow-up: per-property tour pages currently exist only on camellia-site. A future PropFlow-hosted tour page (propflowai.co-served, white-labeled) would make this fully turnkey for properties without marketing sites — out of scope here.
Alternatives considered
- Free-text
sections[]entry ("Virtual tours:"). Zero schema change and text Clara would read it — but voice injection doesn't read sections, there's no structure to match on (which link is the 2BR?), and URL-in-prose invites copy drift. Rejected. - Revive
tourUrlas a single URL. Can't answer "which one?" — Camellia alone has five. Rejected; field removed instead. - Send raw Matterport URLs. Works today, but loses the branded page, the Apply CTA, and swap-ability; also pastes a CoStar-branded asset directly into prospect threads. Rejected for properties that have site pages; the field accepts any URL, so properties without a marketing site can still store raw walkthrough links.
- Voice-side reuse of
send_application_link. It's text-only by an explicit 2026-06-30 decision (single guarded sender, conversation-resolved), and its resolution path is prospect-record-based, not caller-based. A separate small tool is simpler than making that one dual-mode. Rejected. - A generic
send_link_sms(url)voice tool. Maximally flexible, maximally abusable — an LLM-supplied arbitrary URL texted to callers is a prompt-injection exfiltration channel. The tool takes onlybedroomsand resolves the URL server-side. Rejected.