Skip to main content

Acceptance Spec — Realtor Showing Scheduling: Availability Windows + Slots + Two-Sided Confirm (MVP)

  • Status: Proposed (MVP) — 2026-07-03
  • Product: WiseAI Realtor System → AI front office (Aria) + /realtor/app/calendar + /realtor/app/settings/calendar
  • Priority: NOW (revises the booking step of the just-shipped calendar-write MVP before it's demoed to Team Beckett)
  • Related / supersedes-in-part: realtor-calendar-booking-mvp (this spec replaces its §5 "book a firm event immediately" step; it reuses that spec's connect + free/busy + event-write plumbing), ai-front-desk, data-model.md §1.8
  • AI-Bridge: knowledge/architecture/ai-bridge-principle.md

1. Why (problem)

The shipped calendar-write MVP treats "book a showing" as: create a firm calendar event at a precise time and invite the buyer — immediately. Two founder-caught errors make that wrong for real estate:

  1. A showing is two-sided. The buyer's agent doesn't control when a home can be shown — the listing agent, seller, and occupant do. Aria must never drop a confirmed showing onto a time nobody on the listing side agreed to.
  2. A showing is one slot inside a window, not a "busy" block. An agent doing showings "Wednesday evening" can fit ~6 back-to-back showings into 5–8 PM. Treating one showing as blocking the whole window is wrong — availability is capacity within recurring windows, not binary free/busy.

This spec models showing scheduling correctly: recurring agent availability windows → discrete bookable slots (capacity) → a buyer picks a slot → a tentative hold goes on the agent's calendar → the agent confirms with the listing side → only then does it become firm and the buyer is invited.

2. Scope

In: per-agent recurring showing-availability windows (weekday + time range + slot length, deriving capacity); per-agent day-part definitions (what morning/afternoon/evening mean) so Aria can map fuzzy buyer availability; a pure slot-generation function (windows − calendar free/busy − already-booked showings); a re_showings lifecycle (requested → held → confirmed → completed/cancelled); a tentative hold on the chosen slot only (not the window); a confirm task + listing-side contact capture; firm-up + buyer invite on confirm. Aria captures fuzzy windows ("Wednesday evening") and offers concrete open slots.

Out (fast-follow / deferred): travel-time buffers between showings; per-day caps beyond per-window; multi-agent round-robin; one-off blackout dates (a simple vacation block is a nice-to-have, not required); BrokerBay/ShowingTime integration (the real listing-side coordination system — API-blocked, tracked as a partnership ask in AUDIT_realtor_integrations_strategy_2026-07-03.md); Calendly adoption; auto-sending the listing-side outreach (Aria drafts it; a human sends — the platform never auto-sends on the agent's behalf).

3. Data shape (new re_* tables + reuse)

re_agent_showing_prefs -- 1 row per agent (global scheduling prefs)
agent_id uuid PK FK → local_business_agents(id) ON DELETE CASCADE
business_id uuid NOT NULL FK
timezone text NOT NULL DEFAULT 'America/Toronto'
default_slot_minutes int NOT NULL DEFAULT 30
day_parts jsonb NOT NULL DEFAULT
'{"morning":["08:00","12:00"],"afternoon":["12:00","17:00"],"evening":["17:00","20:00"]}'
-- agent-editable: some agents' "evening" starts at 16:00

re_agent_showing_windows -- many rows per agent (recurring weekly availability)
id uuid PK
business_id uuid NOT NULL FK
agent_id uuid NOT NULL FK → local_business_agents(id) ON DELETE CASCADE
weekday smallint NOT NULL -- 0=Sun … 6=Sat
start_time time NOT NULL -- e.g. 17:00
end_time time NOT NULL -- e.g. 20:00 (capacity = (end-start)/slot_minutes)
slot_minutes int NOT NULL DEFAULT 30 -- overrides prefs.default for this window
max_per_window int NULL -- optional lower cap than the derived capacity
active boolean NOT NULL DEFAULT true

re_showings -- the showing lifecycle (one per showing request)
id uuid PK
business_id uuid NOT NULL FK
agent_id uuid NOT NULL FK → local_business_agents(id)
contact_id uuid NULL FK → re_contacts(id) -- the buyer
side text NOT NULL -- 'buyer_showing' (other brokerage's listing)
-- | 'own_listing' (we control the listing side)
listing_id uuid NULL FK → local_business_listings(id) -- when it's ours
listing_mls text NULL
property_address text NULL
listing_side_contact jsonb NULL -- {name, phone, email} for buyer_showing confirm
stage text NOT NULL DEFAULT 'requested'
-- 'requested' (buyer gave availability, no slot held)
-- 'held' (a specific slot is tentatively held on the agent's calendar)
-- 'confirmed' (listing side agreed → firm event + buyer invited)
-- 'completed' | 'cancelled' | 'declined'
preferred_windows jsonb NOT NULL DEFAULT '[]' -- normalized buyer availability
chosen_start timestamptz NULL -- the held/firm slot start
chosen_end timestamptz NULL
calendar_event_id text NULL -- the hold event (upgraded to firm on confirm)
calendar_provider text NULL -- 'google' | 'microsoft'
buyer_invited boolean NOT NULL DEFAULT false -- ONLY true once confirmed
confirm_task_id uuid NULL FK → re_tasks(id)
created_by text
created_at/updated_at timestamptz

Reuse (do not rebuild): re_calendar_connections + the calendar client (free/busy read, event create/update/cancel) from the calendar-write MVP. This spec adds scheduling semantics; the provider plumbing already exists.

4. The core logic (pure + unit-tested)

generateOpenSlots(windows, prefs, freeBusy, bookedShowings, dateRange, buyerWindows?) — a pure function (mirrors the existing booking-logic.ts pattern):

  • Expand each re_agent_showing_windows row across dateRange into candidate slots of slot_minutes (capacity = window length / slot length, capped by max_per_window).
  • Subtract hard conflicts: any slot overlapping the agent's calendar free/busy (a real meeting) is removed.
  • Subtract taken slots: any slot already held/confirmed in re_showings is removed — so a 3-hour window keeps offering its remaining slots (multiple showings per window, the founder's requirement).
  • Fuzzy-window mapping: if the buyer said "Wednesday evening," map via prefs.day_parts.evening → a time range, intersect with that weekday's windows, and return only slots inside it. Support multiple buyer windows ("Wed evening or Mon morning").
  • Returns concrete offerable slots (e.g. Wed 5:00, 5:30, 6:00…).

5. Flow (states)

  1. Capture (Aria, chat/voice): buyer expresses interest in a property + their availability in natural language ("Wednesday evening"). Aria normalizes it into preferred_windows and creates a re_showings row stage='requested' (no hold yet).
  2. Offer: Aria calls generateOpenSlots(...) scoped to the buyer's windows and offers concrete open slots ("I can do Wednesday at 5:00, 5:45, or 6:30"). If the buyer is vague, the request stays requested for the agent to work.
  3. Hold (buyer picks a slot, or agent picks one): set chosen_start/end, place a tentative hold on the agent's calendar for that slot only — titled "Showing requested — 45 Southside (pending listing-side confirmation)" with the AI-arranged disclosure — set stage='held', buyer_invited=false. Create a confirm task (re_tasks type showing) — "Confirm Wed 5:00 showing of 45 Southside with the listing agent" — and, for a buyer_showing, capture/needed listing_side_contact. Aria may draft (never auto-send) the listing-side outreach.
  4. Confirm (human): once the listing side agrees, the agent confirms → the hold event is upgraded to a firm appointment, stage='confirmed', the buyer is now invited (buyer_invited=true), the confirm task closes.
  5. Cancel / decline / reschedule: free the slot (it returns to the open pool), remove/void the calendar event, notify as appropriate. Fails closed — a failed calendar op never leaves a phantom firm booking.

6. AI-Bridge guardrail

Aria proposes slots and places a tentative hold; she never confirms a cross-party time and never marks a showing firm. The firm-up + buyer invite is a human action (or an explicit agent-approved step). The hold event always discloses it was AI-arranged and is pending confirmation. Aria drafts listing-side outreach but does not send it. This keeps showing coordination — an inherently multi-party commitment — on the human side of the bridge.

7. Owner surface

  • Settings → Calendar (or a new "Showing availability" panel): the agent sets weekly windows (weekday + start/end + slot length + optional cap) and their day-part definitions. Simple, editable, defaulted sensibly (e.g. no windows = Aria captures requests but offers no auto-slots until set).
  • Calendar / Lead drawer: a showing shows its stage (Requested / Held / Confirmed) with a one-click Confirm (opens the confirm step) — never an auto-confirm.
  • All authed client calls carry ?account_id=; window/prefs writes UPSERT (per the account_id-in-query + UPSERT-config regression traps).

8. Acceptance criteria

  1. Capacity, not blocking: two showings can be held in the same 5–8 PM window; after holding Wed 5:00, the open-slots for that window still include 5:30, 6:00, … (a window is not consumed by one showing).
  2. Slot generation is pure + tested: generateOpenSlots has unit tests proving window expansion, slot_minutes capacity, max_per_window cap, subtraction of calendar free/busy conflicts, subtraction of already-held slots, and fuzzy-window mapping (incl. an agent whose "evening" = 16:00–20:00, and multiple buyer windows).
  3. Tentative first: picking a slot creates stage='held', a hold event marked pending with the AI disclosure, buyer_invited=false, and a confirm task — the buyer is not invited yet.
  4. Confirm is human + firms up: confirming sets stage='confirmed', upgrades the event to firm, and only THEN invites the buyer (buyer_invited=true).
  5. Two-sided honesty: a buyer_showing requires a listing_side_contact capture / confirm step; Aria never auto-confirms it. own_listing may skip the external contact but still confirms the seller/occupant.
  6. Cancel frees the slot: cancelling a held/confirmed showing returns its slot to the open pool and voids the calendar event; no phantom bookings.
  7. Bridge: every hold event carries the AI-arranged + pending-confirmation disclosure; no code path lets Aria mark a showing confirmed on her own.
  8. account_id + UPSERT on all availability/prefs writes.

9. Verification (deployed URL)

  • Seed an agent with a Wed 17:00–20:00 window (30-min slots) on the TeamMoelker showcase account; via the flow, hold Wed 5:00 for one buyer and confirm the window still offers 5:30+; assert the re_showings row is held, the calendar event is the tentative title, and buyer_invited=false. Confirm it → assert confirmed, firm event, buyer_invited=true. Sample state at ≥2 timepoints (held → confirmed).
  • Unit: node --import tsx --test on the generateOpenSlots suite passes.
  • Negative: a slot overlapping a real calendar event is never offered; a second hold can't take an already-held slot.

This spec revises realtor-calendar-booking-mvp.md §5. Before demoing to Team Beckett, the shipped co-pilot "Confirm → immediate firm event + invite" behavior should be gated/updated to the held→confirm flow above so we never claim a cross-party showing that wasn't agreed to.