AI_Calling Retell HubSpot MCP Server
README.md
# AI_Calling — Retell HubSpot MCP Server
Middle layer between **Retell AI (Cara)**, **HubSpot Scheduler/CRM**, and **Supabase** for tour availability, booking, and call logging.
```
Retell call → Render MCP server → HubSpot Scheduler API + CRM → Supabase logging
```
## Architecture
| MCP tool | Purpose |
|----------|---------|
| `get_tour_availability` | Check HubSpot availability for virtual or in-person tours |
| `book_tour` | Book a tour after guest confirms |
| `log_retell_session` | Save Retell session metadata to Supabase |
| `log_tour_preference` | Log tour interest without booking |
**HTTP routes**
| Route | Auth | Purpose |
|-------|------|---------|
| `GET /health` | No | Render health check |
| `GET /notion/status` | No | Notion sync status + target database info |
| `POST /cron/sync-notion` | Bearer | Backfill/sync `retell_sessions` → Notion |
| `POST /cron/sync-notion-mcp-tools` | Bearer | Publish Retell MCP tools catalog to Notion page section |
| `POST /mcp` | Bearer | MCP Streamable HTTP endpoint for Retell |
| `POST /webhooks/retell` | Bearer | Retell post-call webhook → Supabase |
| `POST /cron/sync-call-data` | Bearer | HubSpot deals (`ai_call_attempted=true`) → Retell calls → Supabase |
---
## Local setup
1. Copy environment file:
```bash
cp .env.example .env
```
2. Fill in `.env` with your real values (never commit `.env`).
3. Run Supabase SQL from [`supabase/schema.sql`](supabase/schema.sql) in the Supabase SQL editor.
4. Install and start:
```bash
npm install
npm run dev
```
5. Verify health:
```bash
curl http://localhost:3000/health
```
---
## Environment variables
Use these **exact names** locally (`.env`) and in Render (**Dashboard → Web Service → Environment**).
| Variable | Description |
|----------|-------------|
| `PORT` | Server port (`3000` locally; Render sets automatically) |
| `NODE_ENV` | `development` or `production` |
| `MCP_SERVER_SECRET` | Random secret; Retell sends `Authorization: Bearer <this>` |
| `HUBSPOT_ACCESS_TOKEN` | HubSpot Private App **Retell Connection** token only |
| `HUBSPOT_API_BASE` | `https://api.hubapi.com` |
| `SUPABASE_URL` | Supabase project URL |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase **service_role** key (server-side only) |
| `DEFAULT_TIMEZONE` | `Europe/Madrid` |
| `HUBSPOT_IN_PERSON_MEETING_URL` | In-person meeting page URL |
| `HUBSPOT_VIRTUAL_MEETING_URL` | Virtual meeting page URL |
| `HUBSPOT_IN_PERSON_SLUG` | `info-madrid` |
| `HUBSPOT_VIRTUAL_SLUG` | `info-madrid/virtual-tour-booking-carabanchel` |
| `DEFAULT_TOUR_DURATION_MINUTES` | `30` |
| `RETELL_API_KEY` | Retell API key (required for call sync) |
| `RETELL_API_BASE` | `https://api.retellai.com` |
| `HUBSPOT_AI_CALL_ATTEMPTED_PROPERTY` | `ai_call_attempted` |
| `HUBSPOT_RETELL_CALL_ID_PROPERTIES` | Comma-separated deal properties that store a Retell call id |
| `SYNC_ENABLED` | `true` to run incremental sync on an interval inside the web service |
| `SYNC_INTERVAL_MS` | `3600000` (1 hour) when `SYNC_ENABLED=true` |
| `SYNC_INITIAL_DELAY_MS` | `60000` — delay before the first incremental run after server start |
| `HUBSPOT_PORTAL_ID` | HubSpot portal ID (optional; auto-fetched if omitted) |
| `TOUR_BOOKING_EMAIL_ENABLED` | `true` to send email after successful `book_tour` |
| `TOUR_BOOKING_NOTIFY_EMAIL` | Comma-separated notification recipients |
| `SMTP_HOST` | e.g. `smtp.gmail.com` |
| `SMTP_PORT` | `587` |
| `SMTP_SECURE` | `false` for STARTTLS |
| `SMTP_USER` | SMTP login (e.g. Gmail address) |
| `SMTP_PASSWORD` | SMTP password or Gmail app password |
| `SMTP_FROM` | From address (defaults to `SMTP_USER`) |
**Where to get secrets**
- **HubSpot token:** Settings → Integrations → Private Apps → **Retell Connection** → Access token
- **Supabase service role:** Supabase → Project Settings → API → `service_role` key
- **MCP secret:** Generate a long random string; use the same value in Retell MCP headers
> **Important:** Do not use the Supabase publishable (anon) key for server-side inserts if RLS is enabled. Use the service role key in Render only — never expose it client-side or in Retell.
> **Important:** Do not use the old **Carabanchel_Direct_Booking_Integration** HubSpot app. Use **Retell Connection** only.
---
## HubSpot scopes required
The **Retell Connection** private app needs:
- `crm.objects.contacts.write`
- `crm.objects.contacts.read`
- `crm.objects.deals.write`
- `crm.objects.deals.read`
- `crm.schemas.deals.read`
- `crm.objects.contacts.read` (contact phone lookup during call sync)
- `crm.schemas.contacts.read`
- `crm.objects.owners.read`
- `automation`
- `scheduler.meetings.meeting-link.read`
- **`meetings-write`** ← required for booking; add before live booking tests
---
## Render deployment
| Setting | Value |
|---------|-------|
| Build command | `npm ci --include=dev && npm run build` |
| Start command | `npm start` |
| Health check path | `/health` |
| Instance | **Starter** recommended (Free tier cold starts can exceed Retell's 20s MCP timeout) |
**Do not use `yarn` or `yarn start`** — this repo uses npm (`package-lock.json`). Using `yarn` alone skips the TypeScript build and can resolve incompatible dependency versions.
**Recommended Render settings:**
| Setting | Value |
|---------|-------|
| Build command | `npm ci --include=dev && npm run build` |
| Start command | `npm start` |
| Health check path | `/health` |
If Render still uses `yarn` for build, the app will start via `tsx` (commit `1a6921c+`) but you should switch to npm for reliable installs. Node **22** is required (see `.nvmrc`).
Add all environment variables from the table above in **Render → Environment**.
After changing build/start commands in the Render dashboard, trigger **Manual Deploy → Clear build cache & deploy**.
---
## Connect Retell MCP
In **Retell → MCP → Add MCP**:
| Field | Value |
|-------|-------|
| MCP server URL | `https://ai-calling-j1hu.onrender.com/mcp` |
| Timeout | `20000` |
| Headers | `Authorization: Bearer <MCP_SERVER_SECRET>` |
Select these tools after deploy:
- `get_tour_availability`
- `book_tour` (requires HubSpot `meetings-write` scope)
- `log_retell_session`
- `log_tour_preference`
Optional webhook (post-call): `POST https://ai-calling-j1hu.onrender.com/webhooks/retell` with the same bearer header.
---
## Cara prompt behavior
When the guest wants a tour:
1. Ask whether they prefer a **virtual** or **in-person** tour.
2. Ask which **day** works best.
3. Ask what **Madrid time** generally works best.
4. Call `get_tour_availability`.
5. Offer **one or two** available slots.
6. Ask clearly: **"Should I book that for you?"**
7. Only after the guest clearly confirms, call `book_tour`.
8. **Do not** say the tour is booked until `book_tour` returns success.
9. If booking fails, say: *"No problem, I'll send you the tour links by WhatsApp so you can choose the time that works best for you."*
10. Log the result with `log_retell_session` or `log_tour_preference`.
**Email for booking:** Map pre-call `get_hubspot_contact` output `email` to the Retell dynamic variable `contact_email`. Cara must pass `email: {{contact_email}}` on every `book_tour` call (also pass `hubspotDealId` / `hubspotContactId` when available). Never ask the guest for email. Never use placeholder addresses.
**Required on every MCP tool call during a live call:**
| Parameter | Retell value |
|-----------|-------|
| `email` | `{{contact_email}}` from pre-call Get Contact output |
| `hubspotDealId` | `{{deal_id}}` |
| `hubspotContactId` | `{{contact_id}}` |
| `sessionId` | `{{call_id}}` |
Never pass JSON `null` for these fields. Never pass placeholder emails such as `need_email@example.com`. If booking fails, use the WhatsApp fallback from the tool response and **do not** tell the guest the tour is booked.
Configure Retell **dynamic variables** so the pre-call HubSpot tools populate `contact_email`, `deal_id`, and `contact_id` before the call starts.
**After a successful booking:** If `TOUR_BOOKING_EMAIL_ENABLED=true`, a React Email notification is sent to `TOUR_BOOKING_NOTIFY_EMAIL` with the tour time plus links to the HubSpot deal and contact records.
---
## Manual test scripts
```bash
# Test HubSpot availability (requires HUBSPOT_ACCESS_TOKEN in .env)
npx tsx scripts/testAvailability.ts
# Test Supabase inserts (requires SUPABASE_* in .env)
npx tsx scripts/testSupabaseInsert.ts
# Test live booking (guarded — only runs when enabled)
RUN_BOOKING_TEST=true TEST_BOOKING_EMAIL=you@example.com npx tsx scripts/testBookTour.ts
# Test tour booking notification email (requires SMTP_PASSWORD)
SMTP_PASSWORD=your_app_password npx tsx scripts/testTourBookingEmail.ts
```
---
## Pre-live checklist
1. Run `supabase/schema.sql` in Supabase
2. Deploy to Render with all env vars
3. Confirm `GET /health` returns `{ "ok": true }`
4. Run `testAvailability.ts` and `testSupabaseInsert.ts`
5. Connect Retell MCP with bearer auth
6. Test a call with **your own** HubSpot contact and phone
7. Add `meetings-write` scope if booking fails with missing scope errors
8. Only then enable for real leads
---
## Supabase tables
- `retell_sessions` — Retell call/session metadata plus HubSpot deal/contact enrichment (upsert by `session_id`)
- `mcp_tool_calls` — MCP tool request/response logs
- `tour_bookings` — Tour bookings and preferences
- `sync_runs` — Audit log for HubSpot deal → Retell call sync jobs
See [`supabase/schema.sql`](supabase/schema.sql) for full schema. Existing projects should also run:
- [`supabase/migrations/20260701_call_sync.sql`](supabase/migrations/20260701_call_sync.sql)
- [`supabase/migrations/20260701_hubspot_enrichment.sql`](supabase/migrations/20260701_hubspot_enrichment.sql)
---
## Notion live sync (`retell_sessions` → Sprints page)
Syncs your existing Supabase `retell_sessions` table into the **Supabase retell_sessions** database on your [Sprints page](https://app.notion.com/p/Sprints-28b762e28bfc8039a24bcee7a5cdb094?p=3ab762e28bfc802b82dbcd3dfc1c05e3&pm=s&t=3ab762e28bfc80bf819b00a91ff27ad9).
No new Supabase tables are required.
### How live sync works
1. **Instant** — every time this app upserts `retell_sessions` (Retell webhook, call sync, MCP tool)
2. **Every 5 minutes** — polls Supabase for rows with a newer `updated_at` and pushes changes to Notion
### Setup (5 steps)
**1. Create a Notion internal integration**
- Go to [notion.so/my-integrations](https://www.notion.so/my-integrations) → **New integration**
- Name: `AI Calling Sync`
- Copy the **Internal Integration Secret** → `NOTION_API_KEY`
**2. Share your Sprints page with the integration**
- Open your Sprints page
- **⋯ → Connections → AI Calling Sync**
- Make sure the **Supabase retell_sessions** database/section is included
**3. Ensure the Notion database exists**
On the Sprints page, under **Supabase retell_sessions**, create (or keep) a **table database** with these columns:
Session ID (title), Call time, Duration (s), Status, Sentiment, Outcome, Contact, Email, Phone, Deal, Deal stage, Agent, Direction, Cost, Total price, Recording, Retell log, call_transcript, transcript_with_tool_calls, Supabase updated
**4. Set Render env vars**
| Variable | Value |
|----------|-------|
| `NOTION_API_KEY` | Internal integration secret |
| `NOTION_SPRINTS_PAGE_ID` | `3ab762e2-8bfc-802b-82db-cd3dfc1c05e3` |
| `NOTION_DATABASE_TITLE` | `Supabase retell_sessions` |
| `NOTION_SYNC_ENABLED` | `true` |
| `NOTION_RETELL_DATABASE_ID` | `3ab762e2-8bfc-81ec-8245-c735f9e2bca1` |
| `NOTION_AUTO_CREATE_DATABASE` | `false` |
Pin `NOTION_RETELL_DATABASE_ID` to your existing database so redeploys and rate limits never create duplicate tables.
**5. Backfill existing rows**
```bash
curl -X POST "https://ai-calling-j1hu.onrender.com/cron/sync-notion?full=true" \
-H "Authorization: Bearer YOUR_MCP_SERVER_SECRET"
```
Check status:
```bash
curl https://ai-calling-j1hu.onrender.com/notion/status
```
### MCP tools catalog on the AI Calling page
Publishes the Retell MCP tool inventory under **MCP/General Tools Implemented or Needed** on the [AI Calling page](https://app.notion.com/p/AI-Calling-3ab762e28bfc802b82dbcd3dfc1c05e3?t=3b2762e28bfc800d8bc100a975c6bd54).
Source of truth: `src/mcp/toolCatalog.ts` (7 tools from `src/mcp/tools.ts`).
```bash
curl -X POST "https://ai-calling-j1hu.onrender.com/cron/sync-notion-mcp-tools" \
-H "Authorization: Bearer YOUR_MCP_SERVER_SECRET"
```
Local:
```bash
npm run sync:notion-mcp-tools
```
Re-runs replace the previous auto-synced block (idempotent).
| Variable | Value |
|----------|-------|
| `NOTION_MCP_TOOLS_SECTION_TITLE` | `MCP/General Tools Implemented or Needed` |
| `NOTION_MCP_TOOLS_SECTION_BLOCK_ID` | `3b2762e2-8bfc-800d-8bc1-00a975c6bd54` |
---
**HubSpot fields stored on `retell_sessions` after sync:**
| Column | Source |
|--------|--------|
| `hubspot_deal_id` | Deal ID |
| `hubspot_deal_name` | Deal name |
| `hubspot_pipeline` | Pipeline label (e.g. Hubs B2C - Carabanchel) |
| `hubspot_deal_stage` | Stage label (e.g. Contacted) |
| `hubspot_deal_stage_id` | Stage internal ID |
| `hubspot_unit_type` | `unit_type__carabanchel_` |
| `hubspot_contract_start_date` | `desired_check_in_date` |
| `hubspot_contract_end_date` | `desired_check_out_date` |
| `hubspot_contact_id` | Associated contact ID |
| `hubspot_contact_name` | Contact first + last name |
| `hubspot_contact_email` | Contact email |
| `hubspot_contact_phone` | Contact phone |
---
## HubSpot deal → Retell call sync
Syncs deals where **`ai_call_attempted` = true** into `retell_sessions`, linked by `hubspot_deal_id`.
**Matching order (first hit wins):**
1. Deal property in `HUBSPOT_RETELL_CALL_ID_PROPERTIES` (default: `retell_call_id`, `retell_session_id`, `ai_retell_call_id`)
2. Retell call `metadata.hubspot_deal_id`
3. All syncable Retell calls to the deal's associated contact phone (connected **and** failed dials: `dial_no_answer`, `dial_busy`, `telephony_provider_permission_denied`)
Failed attempts are stored with `end_reason` = the Retell disconnection reason and `session_outcome` = `failed_dial`.
**One-time historical backfill (local or Render shell):**
```bash
npm run backfill:calls
```
**Ongoing sync — option A: Render Cron Job (recommended)**
| Setting | Value |
|---------|-------|
| Schedule | `0 * * * *` (hourly) |
| URL | `POST https://ai-calling-j1hu.onrender.com/cron/sync-call-data` |
| Header | `Authorization: Bearer <MCP_SERVER_SECRET>` |
**Ongoing sync — Option B: in-process scheduler (active in `render.yaml`)**
Set on the Render web service:
| Variable | Value |
|----------|-------|
| `SYNC_ENABLED` | `true` |
| `SYNC_INTERVAL_MS` | `3600000` |
| `SYNC_INITIAL_DELAY_MS` | `60000` |
| `RETELL_API_KEY` | Your Retell API key |
The server runs an incremental sync ~60s after startup, then every hour. Only deals modified since the last successful sync are re-processed (24h overlap buffer). Overlapping runs are skipped if a previous sync is still in progress.
Redeploy after changing environment variables in Render (**Manual Deploy** if needed).
**Manual full re-sync:**
```bash
curl -X POST "https://ai-calling-j1hu.onrender.com/cron/sync-call-data?full=true" \
-H "Authorization: Bearer <MCP_SERVER_SECRET>"
```
---
## Security
- No secrets in code or `.env.example`
- `.env` is gitignored
- Bearer auth on `/mcp` and `/webhooks/retell`
- Logs scrub Authorization headers and token values
- All tool inputs validated with Zod
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessNo issues