Skip to content

Avoca New Vertical & CRM Integration Playbook

Captured from Avoca's Notion (New Vertical & CRM Integration Playbook 31bf2b56d4d58042bbfed2826eb25775) on 2026-05-01. Internal Avoca reference — do not redistribute. Authors: Christian Scarlett, Omkar Ingale, Bharat Kilaru.

How to add a non-Home-Service vertical (or non-ServiceTitan CRM) to the Avoca workflow system. Reference implementation: Window Nation (Window vertical + Dynamics 365)


🏗 Architecture Overview

Vapi end-of-call webhook
  └─► end-of-call-report.ts        ← entry point, shared across all verticals
        └─► runWorkflow()            ← run-triage.ts  (factory dispatch)
              ├─► HomeServicesWorkflowRunTriager   (vertical = HOME_SERVICE)
                    └─► runSTWorkflow  (crm=Service_Titan)
                    └─► runHCPWorkflow (crm=Housecall_Pro)
              └─► WindowWorkflowRunTriager         (vertical = WINDOW)
                    └─► runDynamicsWorkflow  (crm = DYNAMICS_365)

The system uses a Triager + Factory pattern so each vertical owns its own workflow logic while sharing the common entry point and post-workflow steps.

Key Abstractions

LayerFile(s)Responsibility
Entry pointlib/workflow/post-call/end-of-call-report.tsReceives Vapi webhook, resolves config, calls runWorkflow()
Factorylib/workflow/stages/run/run-triage.tsWorkflowRunTriagerFactory reads typedConfig.vertical and returns the correct triager
Base classlib/workflow/stages/run/WorkflowRunTriager.tsAbstract class — provides run(), expects subclasses to implement determineWorkflowOutcomes() and executeWorkflows()
Vertical triagerrun-triage-home-service.ts, run-triage-window.ts, run-triage-new-vertical.tsVertical-specific outcome determination + workflow dispatch
CRM clientlib/servicetitan/, lib/dynamics365/dynamics.tsGeneric auth + CRUD for a specific CRM
CRM configlib/supabase/dynamics365.ts, lib/supabase/service_titan.tsFetches per-team credentials from dynamics_365_config
Workflow stagelib/workflow/stages/booking-dynamics365/...Business logic for a specific workflow type + CRM combination
Extraction / Promptlib/window-nation/prompt.ts, lib/window-nation/extraction.tsVertical-specific LLM extraction schema and helpers

🔀 Full Data Flow Diagram

                   ┌──────────────────────────────────────────┐
                   │   SINGLE SHARED Vapi Webhook URL         │
                   │   (all verticals, all CRMs, all teams)   │
                   └────────────────────┬─────────────────────┘


                   ┌──────────────────────────────────────────┐
                   │  end-of-call-report.ts                   │
                   │  1. Resolve team → getTypedConfig()      │
                   │  2. Read vertical + crm from config      │
                   │  3. Call runWorkflow(input)              │
                   └────────────────────┬─────────────────────┘


                   ┌──────────────────────────────────────────┐
                   │  WorkflowRunTriagerFactory.create()      │
                   │  switch (vertical)                       │
                   └───────┬──────────────────┬───────────────┘
                           │                  │
      vertical=HOME_SERVICE│                  │vertical=WINDOW
                           ▼                  ▼
         ┌─────────────────────┐   ┌──────────────────────┐
         │ HomeServicesWorkflow│   │ WindowWorkflow       │
         │ RunTriager          │   │ RunTriager           │
         │                     │   │                      │
         │ outcomes:           │   │ outcomes:            │
         │  booking            │   │  booking             │
         │  rescheduling       │   │  rescheduling        │
         │  cancellation       │   │  cancellation        │
         │  eta                │   │  message             │
         │  job-notes          │   │                      │
         │  confirmed-appt     │   │                      │
         │  update-info        │   │                      │
         └────────┬────────────┘   └──────────┬───────────┘
                  │                           │
     switch(crm)  │                           │  switch(crm)
    ┌─────────────┼──────────┐      ┌─────────┼──────────────┐
    ▼             ▼          ▼      ▼         ▼              ▼
┌──────────┐ ┌─────────┐       ┌──────────────┐  ┌──────────────┐
│SERVICE   │ │<FUTURE  │       │DYNAMICS_365  │  │<FUTURE CRM>  │
│_TITAN    │ │ CRM>    │       │              │  │              │
│          │ │         │       │ D365 Booking │  │ Booking      │
│ST Booking│ │Booking  │       │ D365 Resch.  │  │ Resch.       │
│ST Resch. │ │Resch.   │       │ D365 Cancel  │  │ Cancel       │
│ST Cancel │ │Cancel   │       │ D365 Message │  │ ...          │
│ST ETA    │ │...      │       └──────────────┘  └──────────────┘
│ST Notes  │ └─────────┘
│ST Confirm│
│ST Update │
└──────────┘

🪜 Step-by-Step: Adding a New Vertical + CRM

1. Create a Supabase migration for the new CRM

Under apps/web/supabase/migrations/ to store CRM credentials:

sql
CREATE TABLE IF NOT EXISTS <crm>_config (
  id SERIAL PRIMARY KEY,
  team_id INTEGER NOT NULL UNIQUE REFERENCES teams(id),
  -- CRM-specific credential columns ...
  enabled BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

2. Add the new vertical in the vertical enum

In responder_webhook_config, add a migration:

sql
ALTER TYPE vertical ADD VALUE IF NOT EXISTS '<NEW_VERTICAL>';

3. Create the CRM client

lib/<crm-name>/<crm>.ts — CRM API client:

lib/<crm-name>/
  <crm>.ts   ← authenticate(), fetch(), post() — generic, reusable

→ Different verticals using the same CRM can share this client.

4. Create outcome determination

lib/workflow/stages/run/run-type-<vertical>.ts:

ts
export async function determine<Vertical>WorkflowOutcomes({
  transcript,
  teamId,
  customGuidance,
}: { ... }): Promise<WorkflowOutcome[]> {
  // LLM-based outcome classification
  // Return array of { type, guidance }
}

→ Outcome types can differ per vertical. → Always accept customGuidance for team-specific overrides. → Default to [{ type: 'message', guidance: '' }] on empty/error.

5. Create the vertical triager

lib/workflow/stages/run/run-triage-<vertical>.ts:

ts
export class <Vertical>WorkflowRunTriager extends WorkflowRunTriager {
  protected get loggerName() { return 'run<Vertical>Vertical'; }

  protected async determineWorkflowOutcomes(): Promise<WorkflowOutcome[]> {
    return determine<Vertical>WorkflowOutcomes({ ... });
  }

  protected async executeWorkflows(outcomes): Promise<WorkflowTriageResult> {
    // Dispatch based on typedConfig.crm and outcome.type
  }
}

6. Register in the factory

In lib/workflow/stages/run/run-triage.ts, add a branch in WorkflowRunTriagerFactory.create():

ts
class WorkflowRunTriagerFactory {
  static create(input, context): WorkflowRunTriager {
    if (input.typedConfig.vertical === '<NEW_VERTICAL>') {
      return new <Vertical>WorkflowRunTriager(input, context);
    }
    // ... existing verticals
    return new HomeServicesWorkflowRunTriager(input, context);
  }
}

→ Home Service is always the default fallback — no changes needed to run-triage-home-service.ts.

7. Create workflow stage files

Under lib/workflow/stages/<workflow-type>-<crm>/. Each stage follows this structure:

  1. Fetch CRM config — get<Crm>Config(teamId)
  2. Run LLM extraction — vertical-specific prompt + schema
  3. Resolve address — inferAddressFromConversation() + getSearchParams()
  4. Conditionally post to CRM — only for specific call reasons / when appointment is booked
  5. Update call record — upsertCall() with extracted data
  6. Send email notification — using SharedFormattedEmail

8. Create vertical-specific extraction & prompts

lib/<vertical-name>/prompt.ts and lib/<vertical-name>/extraction.ts:

  • prompt.ts: Define the JSON schema the LLM should return, call reasons, and the prompt builder function.
  • extraction.ts: Parse LLM output, normalize data, build the Dynamics/CRM payload.

9. Live tool calls (in-call Vapi tools)

If the vertical needs live tool calls during a Vapi conversation:

lib/tools/<vertical-name>/<toolName>Tool.ts
pages/api/vapi/tools/<vertical-name>/<toolName>.ts
pages/api/vapi/tools/<vertical-name>/__tests__/

→ Use the CRM config from Supabase (no hardcoded env vars for credentials). → Use the shared CRM client from lib/<crm-name>/. → Accept teamId and look up credentials dynamically — no isTest flag needed.


✅ Integration Checklist

  • [ ] Migration: New CRM config table with encrypted secrets + updated_at trigger
  • [ ] Enum: New vertical value (if needed)
  • [ ] Config fetcher: lib/supabase/<crm>.ts
  • [ ] CRM client: lib/<crm-name>/<crm>.ts (auth + CRUD — vertical-agnostic)
  • [ ] Outcome determination: run-type-<vertical>.ts
  • [ ] Triager: run-triage-<vertical>.ts extending WorkflowRunTriager
  • [ ] Factory registration: One if branch in run-triage.ts
  • [ ] Workflow stages: lib/workflow/stages/<type>-<crm>/
  • [ ] Extraction & prompts: lib/<vertical-name>/prompt.ts + extraction.ts

💡 Key Design Principles

1. Centralized webhook — one URL for everything

All verticals and all CRMs share the same Vapi end-of-call webhook (end-of-call-report.ts). Custom integrations never get their own endpoint. Routing is determined by the team's vertical and crm config in the database, not by URL.

2. Home Service is never touched

New verticals add code; they never modify the Home Service workflow.

3. Vertical owns outcomes, CRM owns actions

Outcome determination is per-vertical; CRM interaction is per-CRM. A future vertical using Dynamics 365 can reuse lib/dynamics365/dynamics.ts.

4. One config table per CRM

Team credentials live in the CRM-specific config table (e.g., dynamics_365_config), not in environment variables. Staging vs. prod is just different team rows.

5. Shared utilities stay shared

Address resolution, email sending infrastructure, call record updates, phone formatting — use existing shared libs, don't duplicate.

6. Factory dispatch at one point

The WorkflowRunTriagerFactory is the single place that routes verticals. Everything upstream (webhook handling, config loading) and downstream (automated tasks, result persistence) is shared.