Appearance
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
| Layer | File(s) | Responsibility |
|---|---|---|
| Entry point | lib/workflow/post-call/end-of-call-report.ts | Receives Vapi webhook, resolves config, calls runWorkflow() |
| Factory | lib/workflow/stages/run/run-triage.ts | WorkflowRunTriagerFactory reads typedConfig.vertical and returns the correct triager |
| Base class | lib/workflow/stages/run/WorkflowRunTriager.ts | Abstract class — provides run(), expects subclasses to implement determineWorkflowOutcomes() and executeWorkflows() |
| Vertical triager | run-triage-home-service.ts, run-triage-window.ts, run-triage-new-vertical.ts | Vertical-specific outcome determination + workflow dispatch |
| CRM client | lib/servicetitan/, lib/dynamics365/dynamics.ts | Generic auth + CRUD for a specific CRM |
| CRM config | lib/supabase/dynamics365.ts, lib/supabase/service_titan.ts | Fetches per-team credentials from dynamics_365_config |
| Workflow stage | lib/workflow/stages/booking-dynamics365/... | Business logic for a specific workflow type + CRM combination |
| Extraction / Prompt | lib/window-nation/prompt.ts, lib/window-nation/extraction.ts | Vertical-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:
- Fetch CRM config —
get<Crm>Config(teamId) - Run LLM extraction — vertical-specific prompt + schema
- Resolve address —
inferAddressFromConversation()+getSearchParams() - Conditionally post to CRM — only for specific call reasons / when appointment is booked
- Update call record —
upsertCall()with extracted data - 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_attrigger - [ ] 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>.tsextendingWorkflowRunTriager - [ ] Factory registration: One
ifbranch inrun-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.