Skill: danielfoch/show-booking (version 0.1.0)

--- name: show-booking description: Book real estate showing tours from emailed or pasted listing details, including extracting listing data, preparing outbound call jobs, coordinating a calling sub-agent, creating calendar invites, and returning confirmations. Use when the user asks to book showings for one or more properties, coordinate preferred windows for a client, or automate office calls instead of manual BrokerBay login flows. --- # Show Booking ## Overview Execute an end-to-end workflow for showing requests: 1. Parse intake from free-form prompt or email text. 2. Build per-listing call jobs. 3. Hand off call execution to the `tour-booking` sub-agent. 4. Generate calendar invite files from confirmed slots. 5. Return a concise confirmation summary. ## Inputs Collect these fields before running outbound calls: - Client full name. - Listings (address, listing ID if present, office phone, listing office/agent name if present). - Preferred windows and timezone. - Booking constraints (lockbox notes, occupants, minimum notice). - Confirmation target (email/SMS destination for status updates). If any listing is missing a phone number, flag it as `blocked` and do not place calls for that listing. ## Workflow ### 1) Parse intake Run: ```bash python3 scripts/intake_request.py --input-file /path/to/intake.txt --output /tmp/showing-intake.json ``` Or pass inline text: ```bash python3 scripts/intake_request.py --input-text "Book showings for ..." --output /tmp/showing-intake.json ``` ### 2) Build call queue Run: ```bash python3 scripts/orchestrate_showings.py --intake /tmp/showing-intake.json --output /tmp/showing-plan.json ``` This produces: - `call_queue`: listings with phone numbers ready for calls. - `blocked`: listings missing required data. - `calendar_candidates`: records ready for invite creation after call confirmation. ### 3) Delegate calling to `tour-booking` For each `call_queue` record, invoke `tour-booking/scripts/place_outbound_call.py` with: - Listing metadata. - Preferred windows. - Client identity. - Callback instructions. If live calling is not approved, run with `--dry-run` and return the generated payload. ### 4) Create invites for confirmed slots When a listing returns a confirmed date/time: ```bash python3 scripts/create_invite_ics.py \ --input /tmp/confirmed-showings.json \ --output-dir /tmp/showing-invites ``` The script emits one `.ics` file per confirmed showing. Import into Google Calendar or send directly as attachments. ### 5) Return status Report: - Confirmed showings with time, address, and invite file path. - Pending callbacks. - Blocked listings and the missing field(s). - Total calls attempted and success/failure counts. ## Guardrails - Explicitly identify the caller as an AI assistant acting for the brokerage/realtor. - Respect local telemarketing and consent requirements. - Keep a full audit trail: request payload, call result, booking outcome, and timestamps. - Never claim a showing is confirmed until the call result explicitly indicates confirmation.

path: agents/openai.yaml
content: interface:
  display_name: "Show Booking"
  short_description: "Book showings by calls and calendar workflows"
  default_prompt: "Book showings for provided listings and prepare invites."


path: references/email-intake-template.md
content: # Email Intake Template

Use this format when requesting bookings by email or pasted text:

```
Client: First Last
Timezone: America/Toronto
Preferred windows: Tue-Thu 10:00-16:00, avoid school pickup 15:00-16:00
Constraints: 2-hour notice minimum, no weekends

Listings:
- 12 Example Ave, Toronto ON | MLS #C1234567 | Office phone: (416) 555-0100
- 99 Demo St, Mississauga ON | MLS #W7654321 | Office phone: 905-555-0123
```

Minimum required fields per listing:
- Address
- Office phone

Optional:
- Listing ID
- Agent/office name
- Notes and special access instructions


path: references/integration-notes.md
content: # Integration Notes

## ElevenLabs

- `tour-booking` handles API calls to ElevenLabs.
- Required environment variables for live calls:
  - `ELEVENLABS_API_KEY`
  - `ELEVENLABS_AGENT_ID`
- Optional:
  - `ELEVENLABS_OUTBOUND_URL` (override endpoint URL)

## Calendar

- `create_invite_ics.py` creates `.ics` files for each confirmed showing.
- These files can be imported to Google Calendar manually or attached in confirmation emails.

## Audit Log Fields

Track these fields for each listing:
- `job_id`
- `listing_id`
- `address`
- `office_phone`
- `call_status` (`queued`, `completed`, `failed`, `no_answer`)
- `booking_status` (`confirmed`, `pending_callback`, `not_available`)
- `confirmed_start_time` (ISO 8601)
- `notes`


path: scripts/create_invite_ics.py
content: #!/usr/bin/env python3
"""Generate ICS invite files from confirmed showing JSON."""

from __future__ import annotations

import argparse
import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import uuid4


def to_dt(value: str) -> datetime:
    # Expect ISO 8601 like 2026-02-20T14:00:00-05:00
    return datetime.fromisoformat(value)


def slug(value: str) -> str:
    cleaned = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-").lower()
    return cleaned or "showing"


def format_ics_time(dt: datetime) -> str:
    return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def build_ics(event: dict) -> str:
    start = to_dt(event["start_time"])
    end = to_dt(event.get("end_time")) if event.get("end_time") else start + timedelta(minutes=30)
    uid = f"{uuid4()}@show-booking"
    created = datetime.now(timezone.utc)
    summary = f"Showing: {event['address']}"
    desc = event.get("notes", "Real estate showing booked by AI calling workflow.")
    location = event["address"]

    return "\n".join(
        [
            "BEGIN:VCALENDAR",
            "VERSION:2.0",
            "PRODID:-//show-booking//EN",
            "BEGIN:VEVENT",
            f"UID:{uid}",
            f"DTSTAMP:{format_ics_time(created)}",
            f"DTSTART:{format_ics_time(start)}",
            f"DTEND:{format_ics_time(end)}",
            f"SUMMARY:{summary}",
            f"DESCRIPTION:{desc}",
            f"LOCATION:{location}",
            "END:VEVENT",
            "END:VCALENDAR",
            "",
        ]
    )


def main() -> int:
    parser = argparse.ArgumentParser(description="Create ICS files from confirmed showings JSON.")
    parser.add_argument("--input", required=True, help="JSON array with confirmed showings.")
    parser.add_argument("--output-dir", required=True, help="Directory for .ics files.")
    args = parser.parse_args()

    events = json.loads(Path(args.input).read_text())
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    for idx, event in enumerate(events, start=1):
        filename = f"{idx:02d}-{slug(event['address'])}.ics"
        file_path = output_dir / filename
        file_path.write_text(build_ics(event))
        print(str(file_path))

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/intake_request.py
content: #!/usr/bin/env python3
"""Parse showing intake text into normalized JSON."""

from __future__ import annotations

import argparse
import json
import re
from pathlib import Path


PHONE_RE = re.compile(r"(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}")
LISTING_RE = re.compile(r"(?:mls|listing)\s*#?:?\s*([A-Za-z0-9-]+)", re.IGNORECASE)


def normalize_phone(raw: str) -> str:
    digits = re.sub(r"\D", "", raw)
    if len(digits) == 11 and digits.startswith("1"):
        digits = digits[1:]
    return f"+1{digits}" if len(digits) == 10 else raw.strip()


def parse_listings(text: str) -> list[dict]:
    listings = []
    for line in text.splitlines():
        raw = line.strip()
        stripped = line.strip(" -\t")
        if not stripped:
            continue
        is_listing_hint = raw.startswith("-") or "mls" in stripped.lower() or "office phone" in stripped.lower()
        if not is_listing_hint:
            continue
        phone_match = PHONE_RE.search(stripped)
        listing_match = LISTING_RE.search(stripped)
        listings.append(
            {
                "raw_line": stripped,
                "address": stripped.split("|")[0].strip() if "|" in stripped else stripped,
                "listing_id": listing_match.group(1) if listing_match else None,
                "office_phone": normalize_phone(phone_match.group(0)) if phone_match else None,
            }
        )
    return listings


def parse_request(text: str) -> dict:
    client_match = re.search(r"(?:client|buyer)\s*:\s*(.+)", text, re.IGNORECASE)
    timezone_match = re.search(r"(?:timezone|tz)\s*:\s*([A-Za-z0-9_/\-+]+)", text, re.IGNORECASE)

    return {
        "client_name": client_match.group(1).strip() if client_match else "Unknown Client",
        "timezone": timezone_match.group(1).strip() if timezone_match else "America/Toronto",
        "preferred_windows_text": text,
        "listings": parse_listings(text),
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Parse showing intake into normalized JSON.")
    parser.add_argument("--input-file", help="Path to raw intake text file.")
    parser.add_argument("--input-text", help="Inline intake text.")
    parser.add_argument("--output", required=True, help="Output JSON path.")
    args = parser.parse_args()

    if not args.input_file and not args.input_text:
        raise SystemExit("Provide --input-file or --input-text.")

    if args.input_file:
        text = Path(args.input_file).read_text()
    else:
        text = args.input_text or ""

    result = parse_request(text)
    out = Path(args.output)
    out.write_text(json.dumps(result, indent=2))
    print(str(out))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/orchestrate_showings.py
content: #!/usr/bin/env python3
"""Build call queue and booking plan from intake JSON."""

from __future__ import annotations

import argparse
import json
from datetime import datetime, timezone
from pathlib import Path


def main() -> int:
    parser = argparse.ArgumentParser(description="Create a showing orchestration plan.")
    parser.add_argument("--intake", required=True, help="Path to intake JSON.")
    parser.add_argument("--output", required=True, help="Path to output plan JSON.")
    args = parser.parse_args()

    intake = json.loads(Path(args.intake).read_text())
    listings = intake.get("listings", [])

    call_queue = []
    blocked = []
    for idx, listing in enumerate(listings, start=1):
        if listing.get("office_phone"):
            call_queue.append(
                {
                    "job_id": f"showing-{idx:03d}",
                    "client_name": intake.get("client_name"),
                    "timezone": intake.get("timezone", "America/Toronto"),
                    "listing": listing,
                    "preferred_windows_text": intake.get("preferred_windows_text"),
                    "status": "queued",
                }
            )
        else:
            blocked.append(
                {
                    "listing": listing,
                    "reason": "missing_office_phone",
                    "status": "blocked",
                }
            )

    plan = {
        "created_at_utc": datetime.now(timezone.utc).isoformat(),
        "client_name": intake.get("client_name"),
        "call_queue": call_queue,
        "blocked": blocked,
        "calendar_candidates": [],
    }

    out = Path(args.output)
    out.write_text(json.dumps(plan, indent=2))
    print(str(out))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)