> ## Documentation Index
> Fetch the complete documentation index at: https://docs.offboard.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Public Interview Route

> The public-facing interview page where customers conduct exit interviews via the magic link.

The public interview route at `/e/[workspace_id]` is where customers land when they click a cancel button with an embedded Magic Link. This page conducts anonymous AI exit interviews to understand why customers are leaving.

## Features

* **No authentication required** - Fully public-facing for anonymous access
* **Multi-language support** - AI detects and responds in customer's language
* **Streaming AI conversation** - Real-time responses using Vercel AI SDK
* **Save offer presentation** - Dynamic offer cards via json-render
* **Auto-redirect** - Handles `cancel_url` and `save_url` redirects based on interview outcome
* **PII protection** - Automatic scrubbing before database storage
* **Webhook notifications** - Real-time status updates to merchant systems

## URL Structure

```
/e/[workspace_id]?mode=pre|post&cancel_url=[url]&save_url=[url]&[metadata_params]
```

### Parameters

| Parameter      | Type            | Required | Description                                                                                          |
| -------------- | --------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `workspace_id` | `string`        | Yes      | Your workspace identifier                                                                            |
| `mode`         | `pre` \| `post` | No       | Interview mode — **overrides** workspace `magicLinkMode` config (default: config value, then `post`) |
| `cancel_url`   | `string`        | No       | Redirect URL after cancellation                                                                      |
| `save_url`     | `string`        | No       | Redirect URL if offer accepted                                                                       |
| `interview_id` | `string`        | No       | Resume existing interview                                                                            |
| `*`            | `string`        | No       | Merchant metadata (e.g., `plan`, `value`)                                                            |

> **Mode precedence**: `?mode=` on the magic link wins over the workspace's configured `magicLinkMode`. This lets one workspace serve both blocking (pre) and non-blocking (post) links — e.g., Stripe post-cancel invites always use `?mode=post`.

> **Redirect URL config**: `cancel_url`/`save_url` URL params win over workspace `config.redirectUrls.{cancelUrl,saveUrl}` (the canonical config location — onboarding writes there).

## Modes

### Pre-Cancel Mode (`mode=pre`)

* **Blocking interview** - Customer must complete before canceling
* Use when you want to understand churn before final cancellation
* **Escape affordances**: "Skip" link (mobile top bar) and "×" close button (desktop top bar) plus a "Skip interview →" footer link on desktop — all bail out via `cancel_url`
* **Decision screen**: After interview, customer chooses between "Continue cancelling" or "I'll stay"
* Redirects to `cancel_url` (completed) or `save_url` (offer accepted)

### Post-Cancel Mode (`mode=post`)

* **Non-blocking feedback** - Interview happens after cancellation
* Use when you want feedback without interrupting the cancel flow
* **Thank-you screen**: After interview, shows thank you message with star rating for Offboard.tech
* The AI may present **win-back offers** if save offers are configured
* "Return to {company}" link shown if `cancel_url` is configured
* Redirects to `save_url` if win-back offer accepted

### Layout

The interview page uses a **full-bleed conversation layout** (no card chrome):

* **Top bar** — agent avatar monogram, company name, and "Exit interview with {agent}" subtitle; hosts the pre-mode escape affordance.
* **Conversation column** — centered, max-width \~680px; assistant messages carry the agent avatar, customer replies render as right-aligned bubbles.
* **Input dock** — pill-shaped input with a circular send button tinted by the workspace's `accentColor` when configured.
* **Footer** — "Powered by Offboard" attribution (and the desktop skip link in pre-mode).

All end screens (decision, thank-you, redirecting, error) share the same top bar and a centered content column.

## Implementation

### 1. Add Magic Link to Cancel Button

Replace your cancel button link:

```tsx theme={null}
import { buildMagicLink } from '@/lib/magic-link'

function CancelButton({ customerId, plan }: { customerId: string; plan: string }) {
  const magicLink = buildMagicLink({
    workspaceId: 'ws_abc123',
    mode: 'pre', // or 'post'
    cancelUrl: 'https://yourapp.com/cancelled',
    saveUrl: 'https://yourapp.com/reactivate',
    metadata: {
      customer_id: customerId,
      plan: plan,
      value: plan === 'pro' ? '99' : '29',
    },
  })

  return (
    <a href={magicLink} className="cancel-button">
      Cancel Subscription
    </a>
  )
}
```

### Workspace-Level Redirect URLs

You can also configure default redirect URLs in workspace settings:

```typescript theme={null}
// Workspace config
{
  config: {
    cancelUrl: 'https://yourapp.com/cancelled',  // Default cancel destination
    saveUrl: 'https://yourapp.com/account',      // Default save destination
  }
}
```

**URL Priority** (from highest to lowest):

1. URL params (dynamic per-customer overrides)
2. Workspace config (default fallback)
3. Stay on page (no redirect)

### 2. Handle Interview Completion

The interview lifecycle is **prepare → explicit finalize**:

1. The AI calls `complete_interview`, which scrubs and stores the summary but leaves `status='abandoned'` and emits a `[PREPARED]` marker.
2. The customer then takes an explicit action — "Accept offer & stay", "I'll stay", "Continue cancelling", or "Finish" — which calls `POST /api/e/{workspaceId}/interview/{interviewId}/finalize`.
3. Only finalize writes the terminal status (`completed`/`saved`) and enqueues webhook/billing/email events atomically.

```tsx theme={null}
// Pre-mode: Decision screen
// - "Continue cancelling" → finalize('completed') → cancel_url
// - "I'll stay" → finalize('saved') → save_url

// Post-mode: Thank-you screen
// - Star rating for Offboard.tech feedback
// - "Return to {company}" / "Finish" → finalize('completed') → cancel_url
//   (button always renders — cancel_url is optional)
```

**Mode-Aware Redirects:**

| Mode | Status      | Screen           | Redirect                                |
| ---- | ----------- | ---------------- | --------------------------------------- |
| Pre  | `saved`     | Offer Applied    | `save_url`                              |
| Pre  | `completed` | Decision Screen  | User choice: `cancel_url` or `save_url` |
| Post | `saved`     | Glad you're back | `save_url` (if configured)              |
| Post | `completed` | Thank-you Screen | `cancel_url` (via "Return to" button)   |

### 3. Receive Webhook Notifications

Configure webhook URL to receive real-time updates:

```typescript theme={null}
// Webhook payload
{
  event: 'interview.completed' | 'interview.saved' | 'interview.abandoned',
  workspace_id: string,
  workspace_name: string,
  interview_id: string,
  status: 'completed' | 'saved' | 'abandoned',
  sentiment: 'positive' | 'neutral' | 'negative' | null,
  summary: string, // PII-scrubbed summary
  metadata: {
    plan?: string,
    customer_id?: string,
    // ...any merchant metadata from URL params
  },
  created_at: string
}
```

Example webhook handler:

```typescript theme={null}
// POST /api/webhooks/offboard
export async function POST(request: Request) {
  const payload = await request.json()

  if (payload.event === 'interview.completed') {
    // Handle completed interview
    await cancelSubscription(payload.metadata.customer_id)
    await sendChurnReport(payload.summary)
  } else if (payload.event === 'interview.saved') {
    // Handle saved customer
    await applyRetentionOffer(payload.metadata.customer_id, payload.summary)
  }

  return Response.json({ received: true })
}
```

## AI Tools

The interview agent has access to two tools:

### `present_save_offer`

Presents a save offer when churn reason matches configured conditions:

```typescript theme={null}
{
  offer: string,  // e.g., "20% off for 3 months"
  reason: string  // e.g., "Customer mentioned price concerns"
}
```

### `complete_interview`

Saves the interview and triggers webhook:

```typescript theme={null}
{
  summary: string,              // 2-3 sentence summary
  sentiment: 'positive' | 'neutral' | 'negative',
  status: 'completed' | 'saved',
  saveOfferAccepted?: string    // Which offer was accepted
}
```

## System Prompt

The AI agent is configured with:

* **Agent persona** from workspace settings (name, tone)
* **Product context** (company name, product description)
* **Churn concerns** (common reasons customers leave)
* **Save offers** (available retention offers)
* **Mode** (pre-cancel blocking vs post-cancel feedback)

Example prompt generated per workspace:

```
You are Sarah from Acme Inc. Your tone is Friendly and Professional.

PRODUCT CONTEXT:
- Company: Acme Inc
- Product: Project management tool for teams
- Common churn reasons: price, missing features, complexity
- Available save offers: 20% off for 3 months, Free upgrade to Pro

MODE: Pre-cancel (blocking)
- The customer is considering canceling but has NOT yet canceled.
- Your goal is to understand their reason and, if appropriate, present a save offer.

PRESENTING SAVE OFFERS (pre-mode):
Use the present_save_offer tool when the customer's reason matches a configured condition.
TIMING: Never call present_save_offer before turn 2. Understand the reason first.
LIMIT: Present at most ONE save offer per conversation.

PRESENTING WIN-BACK OFFERS (post-mode):
The customer has already cancelled. You may present a win-back offer if their reason suggests they might return.
Frame it as: "We'd love to have you back — here's a special offer."
TIMING: Never call present_save_offer before turn 2.
LIMIT: Present at most ONE win-back offer per conversation.

QUESTION SEQUENCING — follow this depth ladder:
1. PRIMARY: "What was the main reason for leaving?"
2. CONTEXT: One follow-up based on category
3. IMPACT: "How much did this affect your day-to-day use?"
4. RESOLUTION (pre-cancel only): "Is there anything that would change your mind?"
Rule: ONE question per turn.

HANDLING FRUSTRATION OR ANGER:
- Acknowledge first: "I'm really sorry you had that experience."
- Do NOT defend the product or ask the customer to calm down.
- Ask ONE empathetic question, then move to completion.

NEVER:
- Never argue with the customer or defend the company's decisions.
- Never guilt-trip ("We'll miss you!", "Are you sure?").
- Never present a save offer not explicitly listed in the configured save offers.
- Never ask more than one question per message.
- Never present a save offer before turn 2.
- Never present win-back offers in post-mode if none are configured.
```

## PII Scrubbing

Before any database write, interview summaries go through two-pass PII scrubbing:

### Pass 1: Regex Pre-pass

Removes obvious patterns:

* Email addresses
* Phone numbers
* URLs
* IP addresses
* Credit card numbers
* Social Security Numbers
* UUIDs
* Street addresses

### Pass 2: AI Redaction

Uses Gemini 2.5 Flash to identify and redact:

* Names of people
* Company names
* Locations
* Usernames/handles
* Account/reference numbers

```typescript theme={null}
import { scrubPII } from '@/lib/pii'
import { createAIModel } from '@/lib/ai'

const rawSummary = "John said he's leaving because the price is too high"
const model = createAIModel()
const redactedSummary = await scrubPII(rawSummary, model)
// "[REDACTED] said he's leaving because the price is too high"
```

## Customization

### Agent Persona

Configure in workspace settings:

```typescript theme={null}
{
  agentPersona: {
    name: 'Sarah',
    tone: ['Friendly', 'Professional'],
    saveOffers: [
      '20% off for 3 months',
      'Free upgrade to Pro',
      'Pause subscription for 1 month'
    ]
  }
}
```

### Save Offer Conditions

The AI matches customer reasons to offers:

| Customer says       | Offer presented                           |
| ------------------- | ----------------------------------------- |
| "Too expensive"     | 20% off for 3 months                      |
| "Missing features"  | Ask which features, offer roadmap/upgrade |
| "Just need a break" | Pause for 1 month                         |
| "Switching to X"    | Competitive offer                         |

> **Note:**
>
> * In **pre-cancel mode**: Save offers are presented to prevent cancellation
> * In **post-cancel mode**: Win-back offers may be presented if save offers are configured — these are framed as "We'd love to have you back — here's a special offer"
> * Offers are never presented before the second turn of conversation
> * Only one offer is presented per conversation

### Multi-Language

The AI detects language from the first message and responds in kind:

* Customer: "Hola, ¿por qué te vas?"
* AI: "Hola, soy Sarah de \[Empresa]..."

All UI elements (buttons, cards) are also rendered in the detected language.

## Edge Cases

### No Cookie/Tracker Policy

The `/e/` route intentionally has:

* ❌ No authentication cookies
* ❌ No analytics trackers
* ❌ No pixel tracking
* ✅ Only workspace\_id and URL params

### Interview Abandonment

If a customer leaves mid-interview:

* Status remains `abandoned`
* No webhook is sent
* Interview can be resumed via `interview_id` param

### Minimum Turn Requirement

The `complete_interview` tool checks for:

* **Minimum 3 AI turns** before allowing completion
* Ensures meaningful conversation, not accidental clicks

## Architecture

```
┌─────────────────────────────────────────────────────────────┐
│  Customer clicks Cancel button with Magic Link             │
│  /e/ws_abc123?mode=pre&cancel_url=/cancelled               │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│  Interview Page (/e/[workspace_id]/page.tsx)               │
│  • Loads workspace config                                  │
│  • Renders AIChatUI                                        │
│  • Detects completion marker                               │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│  Chat API (/api/e/[workspaceId]/chat)                     │
│  • Streams responses via Vercel AI SDK                     │
│  • Manages interview state                                 │
│  • Executes tools (present_offer, complete_interview)      │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│  PII Scrubber (/lib/pii/scrubber.ts)                       │
│  • Pass 1: Regex scrubbing                                 │
│  • Pass 2: AI redaction                                   │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│  Database (Supabase interviews table)                     │
│  • status: completed | saved | abandoned                  │
│  • sentiment: positive | neutral | negative                │
│  • transcript_summary: PII-scrubbed                       │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│  Webhook (/lib/webhook/sender.ts)                          │
│  • POST to configured webhook URL                         │
│  • Payload with status, sentiment, summary                │
└─────────────────────────────────────────────────────────────┘
```

## Best Practices

1. **Always provide cancel\_url and save\_url** - Don't leave users stranded
2. **Use mode=pre** for high-value customers you want to retain
3. **Use mode=post** for low-touch cancellation flows
4. **Configure meaningful save offers** - Match offers to common churn reasons
5. **Set up webhooks** - Get real-time notifications for follow-up
6. **Test magic links** - Verify redirects work in production
