Architecture

Data model, Inngest, webhook logs, sync voice dispatch.

A reference for anyone debugging Coachbot's internals — or extending them.

Data model (high level)

The interesting tables:

TableWhat it holds
workspacesOne row per workspace. JSONB data blob carries the agent persona / offer / pricing / qualification config. Channel + accent + retention live as columns for indexing.
leadsOne row per lead. Workspace-scoped. Phone is unique within (workspace_id, phone). JSONB form_responses (voice Form builder answers) and metadata (Public API bag) hang off this.
messagesOne row per WhatsApp turn. role = user / assistant. References leads.id with cascade delete.
call_sessionsOne row per Vapi voice call. vapi_call_id unique. Holds transcript, summary, recording URL, duration, ended reason.
workspace_integrationsOne row per (workspace, kind). kind ∈ twilio / calcom / openai / vapi. encrypted_data is AES-256-GCM ciphertext.
workspace_members(user_id, workspace_id, role). Role ∈ owner / admin / agent.
workspace_invitesPending invites with expiry.
api_keysPer-workspace Public API credentials. Hashed (SHA-256). Soft-revoked.
webhook_logsVoice diagnostic log — every Vapi-related boundary crossing. Backs the Logs page.
users, sessions, accounts, verificationsBetter Auth schema.

Soft delete only for api_keys (revoked_at). Everything else is hard delete with cascade.

Background jobs (Inngest)

Two functions, registered in app/api/inngest/route.ts:

agent-turn — fires every WhatsApp outbound

Triggered by agent/turn.requested. Three durable steps so a transport failure never re-bills the OpenAI call:

  1. generate-reply — load workspace settings + full conversation history → build the system prompt → call OpenAI with tools.
  2. send-message — resolve Twilio creds inside the step (auth token never crosses the serialisation boundary), send via WhatsApp.
  3. persist-message — write the assistant message + update lead status atomically.

Concurrency: 1 per lead (so two rapid inbound messages serialise against a fresh history), 5 globally (free Inngest plan ceiling).

retention-sweep — daily at 03:00 UTC

Iterates every workspace with retention_days set, deletes leads older than the cutoff via cascade. Each workspace gets its own step.run so one failure doesn't block the others.

Voice dispatch is sync (not Inngest)

The voice channel doesn't use Inngest. Form submit → dispatchVoiceCall() is called inline in the route handler, the result is returned in the same HTTP response. Two reasons:

  1. Errors stay visible. With async, Vapi failures landed in the void — the form returned 201 successfully even when no call ever happened. Sync surfaces the exact Vapi error in the response body.
  2. No "is Inngest configured?" debugging. Voice doesn't depend on Inngest Cloud env vars or app sync. One less moving piece to misconfigure.

Trade-offs:

  • No automatic retry on transient Vapi 5xx (but Vapi is fast and reliable; retries help less than retry-with-backoff helps OpenAI).
  • Higher latency on form submit (~300–600ms vs ~50ms enqueue). Imperceptible to the user.

The same dispatchVoiceCall() helper is shared by:

  • /api/forms/<slug>/leads (public form)
  • /api/public/leads (Public API)
  • /api/admin/workspaces/<slug>/test-call (Test page)

WhatsApp still uses Inngest because the OpenAI step alone routinely takes 15–30 seconds — well over Vercel hobby's 10s function ceiling.

Vapi webhook routing

Coachbot exposes three classes of Vapi inbound:

PathVapi event typeCoachbot does
/api/vapi/<slug>/tools/<tool>tool-calls (one per URL)Dispatch to the named tool, return { results: [...] }
/api/vapi/<slug>/eventsstatus-updateUpdate call_session.status. If status=ended, build transcript from artifact.messages and save.
/api/vapi/<slug>/eventsend-of-call-reportUpgrade transcript with Vapi's clean version, save summary + recordingUrl + durationSeconds.

Every inbound is logged to webhook_logs and visible on /admin/<slug>/logs.

Encryption

INTEGRATIONS_ENCRYPTION_KEY is a 32-byte hex secret. Used to AES-256-GCM-encrypt the encrypted_data column of workspace_integrations. Lose the key and every workspace's stored credentials become unrecoverable.

Plaintext credentials never cross an Inngest step boundary — the agent-turn function resolves Twilio creds inside the send-message step, so the auth token isn't serialised between steps.

Status callback (Twilio, not implemented)

WhatsApp outbound deliveries are fire-and-forget — Coachbot doesn't currently subscribe to Twilio's status callback. That means errors like 63016 (24-hour window closed) succeed at the API level + fail at delivery without Coachbot knowing.

Adding a Status Callback URL on the Twilio Messaging Service + a route at /api/whatsapp/status to handle delivery updates would close this gap. It's on the roadmap but not shipped.

Roadmap notes

These are documented because the architecture supports them — they just haven't shipped:

  • WhatsApp Message Templates — required for cold business-initiated WhatsApp messages. Stored as a per-workspace templateContentSid on the Twilio integration row.
  • Webhooks for the Public API — POST to a customer-configured URL on lead status changes. HMAC-signed. Retries + delivery tracking.
  • Voice call retry policy — currently single-attempt. Adding "retry on no-answer in 30 minutes" would mean re-adding Inngest for voice specifically.
  • Per-API-key scopes — currently every key is full-access to its workspace. Adding read-only / submit-only scopes is straightforward (column on api_keys, route guards).