# Proposal — Calendar Booking Handoff (Calendly / Cal.com / Google Calendar)

**Status:** draft (audit iter 10, 2026-05-16)
**Owner:** TBD
**Effort:** 6 days (3 backend, 2 frontend/widget, 1 docs+QA)
**Plan gate:** Pro+ tier; Free tier sees "Upgrade" placeholder

---

## Problem

Today when a visitor wants to book a meeting (demo / consult / sales
call), the conversation hits a dead end:

- Bot can answer "When can I book a call?" with a static URL only if
  the operator wrote a curated answer with the Calendly link.
- Visitor leaves the widget → opens Calendly in a new tab → drops off
  ~40% of the time per industry benchmarks (Intercom Q3 2025 report).

Buyer feedback (recurring across batches):
> "Half the value of a sales widget is letting the lead pick a slot
> right there. Without that I'm essentially just running a glorified
> contact form." — ttsoft, batch v2

Competitors already ship this:
- **Intercom** — direct Calendly + Google Calendar embed in messenger.
- **Drift** — proprietary scheduler tied to rep round-robin.
- **HubSpot Chat** — embedded HubSpot Meetings widget.

Pitchbar without booking loses every demand-gen comparison.

---

## Goals

1. Workspace admin connects a calendar provider once (Calendly link,
   Cal.com link, or Google Calendar OAuth).
2. The bot — when intent matches "book a call / demo / consult" — emits
   a `<booking/>` block marker (same protocol as `<product/>` etc).
3. Widget renders an inline date+time slot picker (or fallback iframe
   for providers that don't expose slot APIs).
4. Slot selection POSTs to `/api/v1/widget/booking` → server proxies to
   provider → confirmation written back to conversation as an
   `agent` message with the booking link.
5. Lead row updated with `booking_at` + `booking_provider` for the
   inbox to filter.

---

## Non-goals

- **No proprietary calendar.** We never own slot availability; always
  proxy to a provider the visitor can manage themselves.
- **No real-time slot availability before the visitor opens the
  picker.** Slot fetch happens on widget interaction, NOT on every
  conversation start (would blow caches).
- **No multi-rep round-robin in v1.** Workspace has one default
  calendar. Per-agent calendar override is v2.
- **No CRM event creation in v1.** RouteLeadJob already pushes Lead
  to HubSpot / Salesforce; we add `booking_at` to that payload —
  separate event creation is out of scope.

---

## Provider abstraction

New service contract `App\Services\Booking\Contracts\Calendar`:

```php
interface Calendar
{
    /**
     * Return slots openable within the next $daysAhead days,
     * grouped by date. Cached per-workspace for 5 minutes.
     *
     * @return array<int, array{date: string, slots: array<int, array{start: string, end: string, token: string}>}>
     */
    public function availableSlots(Workspace $workspace, int $daysAhead = 14): array;

    /**
     * Book the slot represented by an opaque token from availableSlots().
     * Returns confirmation payload or throws BookingFailedException.
     *
     * @return array{booking_id: string, join_url: ?string, when: string}
     */
    public function book(Workspace $workspace, string $slotToken, array $visitor): array;

    /** Human-readable provider name for the widget badge. */
    public function providerLabel(): string;
}
```

Real implementations:

| Provider          | Auth model                | Slot API    | Booking API | Notes |
|-------------------|---------------------------|-------------|-------------|-------|
| CalcomCalendar    | API token + event_type ID | yes (REST)  | yes         | Native slots — best UX |
| GoogleCalendar    | OAuth (existing OAuthState) | freebusy    | events.insert | Round-trip 2 API calls |
| CalendlyEmbed     | shareable URL only        | n/a         | n/a (iframe) | Fallback — render `<iframe>` only |
| FakeCalendar      | -                         | deterministic | deterministic | tests |

Resolution: `Calendar::class` bound in `AppServiceProvider::register`
based on `workspaces.booking_provider` enum column. Fallback to
`CalendlyEmbed` when only a URL is configured.

Slot results cached `cache()->remember("booking:slots:{$workspace_id}", 300, …)`.

---

## Data model

New migration:

```php
Schema::table('workspaces', function (Blueprint $table) {
    $table->string('booking_provider', 32)->nullable()->after('plan_id');
    $table->json('booking_config')->nullable()->after('booking_provider');
});

Schema::table('leads', function (Blueprint $table) {
    $table->string('booking_provider', 32)->nullable()->after('routed_to');
    $table->timestampTz('booking_at')->nullable();
    $table->string('booking_join_url')->nullable();
});

Schema::table('plans', function (Blueprint $table) {
    $table->boolean('booking_enabled')->default(false);
});
```

`booking_config` stored fields (per provider):
- Cal.com: `{api_token, event_type_id, timezone}`
- Google: `{refresh_token, calendar_id, event_template}`
- Calendly: `{share_url}` — no API key needed for embed

Token fields encrypted via the existing Eloquent encrypt cast on the
`booking_config` JSON.

---

## Bot integration

Add to `ToolRegistry::forAgent($agent)` (gated by capability
`booking_handoff`, only enabled when workspace has a configured
provider):

```php
'book_meeting' => [
    'type' => 'function',
    'function' => [
        'name' => 'book_meeting',
        'description' => 'Show the visitor a date+time slot picker so they can book a meeting with our team.',
        'parameters' => ['type' => 'object', 'properties' => []],
    ],
],
```

Tool call → handler emits `<booking provider="cal" widget_id="…"/>`
block marker. `InlineBlockParser` already strips these out and emits
`block` SSE events. Widget renderer for the booking block lives in
`resources/widget/src/ui/blocks/booking.tsx`.

If tool calling is fragile on the model (Workers AI Llama 3.3), fall
back to a prompt fragment that instructs the model to emit the marker
inline when visitor intent matches (same pattern as `<product/>` for
ecommerce).

---

## Widget UI

New `resources/widget/src/ui/blocks/booking.tsx`:
- Compact card with provider logo + "Pick a time" heading.
- Inline horizontal scroll of next 7 days as pills.
- Tap a day → vertical list of available slots.
- Tap a slot → POST to `/api/v1/widget/booking` with `{ slot_token,
  visitor_name, visitor_email }`. If lead form not filled yet, prompt
  inline for name+email first.
- On confirmation: replace card with "Booked! Calendar invite sent to
  <email>" + "Add to my calendar" link.

Calendly fallback: render the share_url in an `<iframe>` with
`sandbox="allow-scripts allow-forms allow-same-origin"`. No native
picker, but functional.

Hard size budget reminder: widget gzipped ≤ 50KB. Booking block
contributes ~3KB; rough budget breakdown logged in `docs/PLAN.md`.

---

## Hot-path safety

Zero new work on the visitor → first-token path. The booking block is
emitted from the LLM's output AFTER first token. Slot fetch happens
when widget renders the booking card (separate API call). Booking
confirmation is also a separate request.

Server-side handlers go in:
- `app/Http/Controllers/Widget/BookingSlotsController.php` (GET) —
  rate-limited 30/min per JWT.
- `app/Http/Controllers/Widget/BookingConfirmController.php` (POST) —
  rate-limited 5/min per JWT, signed slot_token prevents replay.

Both controllers verify WidgetJwt, scope to conversation's workspace,
and bail if `booking_provider IS NULL` for the workspace.

---

## Test plan

Pest feature tests:
- Cal.com `availableSlots()` returns parsed structure
- Cal.com `book()` writes Lead booking_at + booking_join_url
- Google Calendar `availableSlots()` filters busy intervals
- Calendly fallback emits iframe-friendly URL
- Slot picker rate-limited at 30/min
- Booking confirm rate-limited at 5/min
- Cross-tenant: workspace A's slots never returned to workspace B's
  visitor
- Plan-gated: Free workspace returns 402 from booking endpoints
- Lead update post-book includes booking_at in CRM webhook payload

UI test plan (board card close):
1. Sign in as Pro workspace admin, /settings/integrations.
2. Connect Cal.com via API token + event type.
3. Open the workspace's demo agent in playground.
4. Send "can I book a demo?" → confirm booking card renders.
5. Pick a slot → confirm "Booked!" state + Lead row visible in inbox
   with `booking_at` set.
6. Sign in as Free workspace → repeat step 4 → confirm "Upgrade" CTA.

---

## Rollout

1. Phase 1 (3 days): Cal.com + FakeCalendar + DB migration + booking
   block + widget renderer. Plan gate. Tests.
2. Phase 2 (2 days): Google Calendar OAuth flow (reuse existing
   OAuthState support struct from Slack / OAuth-sources).
3. Phase 3 (1 day): Calendly embed fallback + docs + nav.
4. Canary: Pitchbar's own marketing demo agent first.
5. Doc page `booking-handoff.blade.php` + `troubleshooting-booking.blade.php`.

---

## Risks / open questions

- **Provider rate limits.** Cal.com is 100req/min/api-key. Slot cache
  (5 min) keeps us well under for any reasonable workspace. Google's
  `freebusy` is per-user OAuth — same.
- **Timezone confusion.** Server always serializes UTC; widget
  converts to visitor's `Intl.DateTimeFormat().resolvedOptions().timeZone`.
  Tested on Safari/iOS where this resolves quirkily.
- **Cancellations / reschedules.** v1 doesn't handle these in-widget;
  the calendar invite contains the provider's reschedule link.
- **What if Cal.com / Google is down?** Booking endpoint returns 503;
  widget falls back to "Please email us at <admin-email>". Not
  exposing a real-time outage to the visitor — the bot continues the
  conversation.

---

## Why now

- Top buyer ask for 4+ months — every batch surfaces it again.
- Engineering scope clean: provider abstraction + DB columns +
  widget block + 2 endpoints. No hot-path risk.
- Closes the biggest competitive gap vs Intercom / Drift.
- 6-day budget tight but feasible — well-defined surface.
