Skill: danielfoch/tour-booking (version 0.1.0)

--- name: tour-booking description: Sub-agent for outbound listing-office calls to request and confirm property showing slots using a provided call script and structured payloads. Use when a parent workflow needs call execution for one or more listings, including dry-run payload generation, live ElevenLabs call requests, and parsing call outcomes into booking statuses. --- # Tour Booking ## Overview Handle the call execution layer for property showing bookings: 1. Build a consistent call prompt from listing and client data. 2. Send outbound call request to ElevenLabs (or dry-run). 3. Normalize call outcome into structured status fields. ## Inputs Each call job should include: - `job_id` - `client_name` - `listing.address` - `listing.office_phone` - `preferred_windows_text` - `timezone` ## Runbook ### 1) Build payload ```bash python3 scripts/prepare_call_payload.py \ --job /tmp/job.json \ --output /tmp/call-payload.json ``` ### 2) Place call Dry-run (default safe mode): ```bash python3 scripts/place_outbound_call.py \ --payload /tmp/call-payload.json \ --output /tmp/call-result.json \ --dry-run ``` Live mode: ```bash python3 scripts/place_outbound_call.py \ --payload /tmp/call-payload.json \ --output /tmp/call-result.json \ --live ``` ### 3) Parse outcome ```bash python3 scripts/parse_call_result.py \ --input /tmp/call-result.json \ --output /tmp/booking-outcome.json ``` ## Call Guardrails - State clearly that the caller is an AI assistant calling on behalf of the realtor. - Ask for available slots inside the requested window first; request alternatives if unavailable. - Confirm final slot with exact date and local time before ending the call. - If the office cannot confirm, mark as `pending_callback` and capture callback requirements.

path: agents/openai.yaml
content: interface:
  display_name: "Tour Booking"
  short_description: "Call listing offices and normalize booking outcomes"
  default_prompt: "Place and summarize listing-office calls for showing bookings."


path: references/call-script-template.md
content: # Call Script Template

Use this baseline script for listing office calls:

1. Introduction
- "Hi, this is an AI assistant calling on behalf of Realtor <NAME>."
- "I’m calling to request a showing for <ADDRESS>."

2. Scheduling request
- Provide buyer/client name if required.
- Ask for available windows inside preferred times first.

3. If no match
- Ask for next two available alternatives.
- Ask if there are constraints (notice period, occupant approval, lockbox windows).

4. Confirmation
- Repeat exact date/time and timezone.
- Confirm any access instructions.
- Confirm callback number or email for changes.

5. Close
- Thank them and restate the confirmed slot or callback expectation.


path: references/elevenlabs-env.md
content: # ElevenLabs Environment Variables

Required for live outbound calls:
- `ELEVENLABS_API_KEY`
- `ELEVENLABS_AGENT_ID`

Optional:
- `ELEVENLABS_OUTBOUND_URL`
  - Defaults to `https://api.elevenlabs.io/v1/convai/phone-calls` in this skill.
  - Override if ElevenLabs changes endpoint path or your account uses a different route.

Recommended safety defaults:
- Run dry-run first for every new workflow.
- Allow `--live` only after payload inspection.


path: scripts/parse_call_result.py
content: #!/usr/bin/env python3
"""Normalize raw call result into booking status."""

from __future__ import annotations

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


def classify_status(call: dict) -> str:
    if call.get("mode") == "dry_run":
        return "pending"

    signal_fields = {
        "status": call.get("status"),
        "api_response": call.get("api_response"),
        "error_body": call.get("error_body"),
        "error": call.get("error"),
    }
    text = json.dumps(signal_fields).lower()
    if "confirmed" in text or "booked" in text:
        return "confirmed"
    if "callback" in text or "call back" in text:
        return "pending_callback"
    if "no answer" in text or "voicemail" in text:
        return "no_answer"
    if call.get("status") == "error":
        return "failed"
    return "pending"


def main() -> int:
    parser = argparse.ArgumentParser(description="Normalize call result JSON.")
    parser.add_argument("--input", required=True, help="Call result JSON.")
    parser.add_argument("--output", required=True, help="Normalized result JSON.")
    args = parser.parse_args()

    call = json.loads(Path(args.input).read_text())
    normalized = {
        "processed_at_utc": datetime.now(timezone.utc).isoformat(),
        "job_id": call.get("request_payload", {}).get("metadata", {}).get("job_id"),
        "booking_status": classify_status(call),
        "raw_status": call.get("status"),
        "raw_result": call,
    }

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


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


path: scripts/place_outbound_call.py
content: #!/usr/bin/env python3
"""Place outbound call through ElevenLabs API, or dry-run."""

from __future__ import annotations

import argparse
import json
import os
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path


DEFAULT_OUTBOUND_URL = "https://api.elevenlabs.io/v1/convai/phone-calls"


def post_json(url: str, headers: dict[str, str], payload: dict) -> dict:
    req = urllib.request.Request(
        url=url,
        data=json.dumps(payload).encode("utf-8"),
        headers=headers,
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read().decode("utf-8"))


def main() -> int:
    parser = argparse.ArgumentParser(description="Call listing office via ElevenLabs.")
    parser.add_argument("--payload", required=True, help="Prepared payload JSON.")
    parser.add_argument("--output", required=True, help="Output result JSON path.")
    parser.add_argument("--dry-run", action="store_true", help="Only emit prepared request.")
    parser.add_argument("--live", action="store_true", help="Execute live API call.")
    args = parser.parse_args()

    if args.dry_run and args.live:
        raise SystemExit("Choose --dry-run or --live, not both.")
    if not args.dry_run and not args.live:
        args.dry_run = True

    payload = json.loads(Path(args.payload).read_text())

    if args.dry_run:
        result = {
            "mode": "dry_run",
            "created_at_utc": datetime.now(timezone.utc).isoformat(),
            "request_payload": payload,
            "status": "queued",
        }
    else:
        [REDACTED_SECRET]"ELEVENLABS_API_KEY")
        agent_id = os.environ.get("ELEVENLABS_AGENT_ID")
        if not api_key or not agent_id:
            raise SystemExit("Set ELEVENLABS_API_KEY and ELEVENLABS_AGENT_ID for --live mode.")

        outbound_url = os.environ.get("ELEVENLABS_OUTBOUND_URL", DEFAULT_OUTBOUND_URL)
        request_payload = {
            "agent_id": agent_id,
            "to_number": payload["to_number"],
            "system_prompt": payload["system_prompt"],
            "metadata": payload.get("metadata", {}),
        }
        headers = {
            "Content-Type": "application/json",
            "xi-api-key": api_key,
        }
        try:
            api_response = post_json(outbound_url, headers, request_payload)
            result = {
                "mode": "live",
                "created_at_utc": datetime.now(timezone.utc).isoformat(),
                "status": "submitted",
                "request_payload": request_payload,
                "api_response": api_response,
            }
        except urllib.error.HTTPError as exc:
            body = exc.read().decode("utf-8", errors="replace")
            result = {
                "mode": "live",
                "created_at_utc": datetime.now(timezone.utc).isoformat(),
                "status": "error",
                "http_status": exc.code,
                "error_body": body,
                "request_payload": request_payload,
            }
        except Exception as exc:  # noqa: BLE001
            result = {
                "mode": "live",
                "created_at_utc": datetime.now(timezone.utc).isoformat(),
                "status": "error",
                "error": str(exc),
            }

    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/prepare_call_payload.py
content: #!/usr/bin/env python3
"""Prepare a structured outbound-call payload from a booking job."""

from __future__ import annotations

import argparse
import json
from pathlib import Path


def main() -> int:
    parser = argparse.ArgumentParser(description="Build outbound payload from job JSON.")
    parser.add_argument("--job", required=True, help="Path to a single call job JSON.")
    parser.add_argument("--output", required=True, help="Path for outbound payload JSON.")
    args = parser.parse_args()

    job = json.loads(Path(args.job).read_text())
    listing = job["listing"]
    prompt = (
        "You are calling a listing office to book a property showing. "
        "State you are an AI assistant calling for a realtor and ask for available showing "
        f"slots for {listing['address']} for client {job['client_name']}. "
        f"Preferred windows: {job.get('preferred_windows_text', 'not provided')}. "
        "If unavailable, request the next two alternatives and callback instructions. "
        "Repeat the final agreed slot before ending."
    )

    payload = {
        "job_id": job["job_id"],
        "to_number": listing["office_phone"],
        "metadata": {
            "job_id": job["job_id"],
            "client_name": job["client_name"],
            "address": listing["address"],
            "listing_id": listing.get("listing_id"),
            "timezone": job.get("timezone", "America/Toronto"),
        },
        "system_prompt": prompt,
    }

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


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


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