Appearance
Internal CRM abstraction
The thesis. The post-call workflow should be blackboxable: as long as the system passes it the right info, it should work, regardless of which CRM sits behind it. That property requires a boundary that doesn't exist today: a canonical internal CRM interface that the workflow talks to, with per-CRM adapters behind it. This page assesses whether such an interface could map to every existing CRM integration comprehensively, using the actual integration inventory as evidence.
Related: the cross-engagement version
Canonical CRM + Declarative Overlay is the generalized form of this idea (declarative, runtime-readable per-CRM overlays with capability guards and fallbacks). This page is the Avoca-grounded, testing-motivated subset: interface + adapters + reference implementation, feasibility argued from the actual integration inventory below. The overlay DSL is a later ambition; the capability flags here are its minimum viable form.
Problem
CRM-specific code is reachable from the post-call workflow through enum-keyed branching at multiple sites, not through an interface:
booking-triage.ts(getBookingResult): an if/else chain ontypedConfig.crmselectingbookJobInST,bookJobInAccuLynx,bookJobInFourSeasons,bookJobInNeighborly, ...- Per-vertical triagers (e.g.
run-triage-auto-service.ts) with their own CRM switches - 14+ per-CRM stage directories under
apps/web/lib/workflow/stages/(booking-autoops/,booking-hcp/,booking-jobnimbus/,booking-oasis/,booking-pestpac/,booking-fieldroutes/,booking-fieldedge/, three Salesforce variants,booking-acculynx-roofing/,booking-albiware/,booking-dynamics365/, ...) - A parallel
lib/non-st-workflow/tree with its own CRM implementations (fieldRoutes, jobber, jobNimbus, hcp, workiz, clypboard, servpro-playwright, iaa)
Consequences: the post-call cannot be tested once (every CRM path is its own test problem); adding a CRM means touching dispatch sites plus writing a full stage directory; and "did the workflow decide the right thing" is entangled with "did CRM X's client execute it," so a test failure doesn't localize.
Current shape
Each branch has its own input shape, its own result shape, its own error semantics. There is no point where "the workflow's intent" exists as a first-class, CRM-independent value you could assert on.
The proposed shape
Three artifacts:
- The canonical interface:
searchCustomer,createCustomer,getAvailability,getServices,book,cancel,reschedule, plus per-adapter capability flags (supportsCancel,supportsAvailability, ...) so the workflow can branch on capability instead of on CRM identity. - Per-CRM adapters wrapping the existing client libraries. The clients don't get rewritten; they get fronted.
- A reference implementation: a stateful in-memory CRM implementing the same interface. This is "the internal CRM" in the testing sense, and it replaces per-CRM mock construction with one implementation that stands in for all of them.
Could it map comprehensively? The evidence
Verified observations
Inventory and operation surfaces below come from a code survey of avoca-next main run 2026-07-03 (directory and file paths read directly; completeness judgments are the surveyor's). Treat per-CRM details as strong leads, verify before building against any single one.
The integration inventory
19 CRM enum values. The spread, by integration depth:
| Tier | CRMs | Character |
|---|---|---|
| Deep | ServiceTitan (17 generated API modules, writeback, crm-sync), AutoOps, Jobber (GraphQL, full ops) | Full clients, multiple ops, live post-call stages |
| Mid | Housecall Pro, PestPac, FieldRoutes, Four Seasons, Neighborly, Salesforce (+ Kelso and Omnia per-client variants) | Working clients, narrower op surfaces |
| Minimal | FieldEdge, Oasis, Albiware, Dynamics 365, HubSpot, JobNimbus | Partial or single-purpose |
| Browser-automated | AccuLynx, ServPro (Playwright) | No API; driven through the UI |
| No integration | NO_CRM teams | Workflow-only (messages, emails) |
The operation surfaces converge
The three deepest integrations, side by side:
| Canonical op | AutoOps | Jobber | ServiceTitan | HCP |
|---|---|---|---|---|
| search customer | searchCustomer (by phone/id) | searchClientByPhone | FindCustomer | find/search customer |
| create customer | (via book) | createClient | CreateCustomer | ✓ |
| get availability | getAvailability | getScheduledItems (inverse) | GetAvailability/GetSchedules | ✓ |
| get services | listServices | (job/quote/request types) | GetJobTypes/GetServiceTypes | ✓ |
| book | createJob | createJob / createQuote / createRequest | CreateJob + BookAppointment | create job |
| cancel | cancelJob | deleteVisit | CancelJob | ✓ |
| reschedule | rescheduleJob | editVisitSchedule | UpdateJob/appointment | ✓ |
The core seven ops appear, under different names and slightly different entity models, in every integration deep enough to matter.
Why convergence is structural, not lucky
The question "can we model every CRM comprehensively?" is the wrong question, and the right one has a better answer. We don't need to model CRMs comprehensively (ServiceTitan alone has 17 API modules; nobody models that). We need to model Avoca's usage comprehensively, and Avoca's usage is fixed by the product: a voice agent that looks up callers, quotes availability, and books/cancels/reschedules. The demand side is a closed set. That's why every integration, built independently, converged on the same seven ops.
The WireMock mock-CRM work is the empirical proof for one CRM: the stub inventory needed to make full end-to-end EAS calls work IS the de-facto canonical surface for AutoOps, and it's small.
Where it strains, and what absorbs the strain
| Divergence | Example | Absorber |
|---|---|---|
| Entity-model mismatch | Jobber reschedules a visit, not a job; ST splits job vs appointment; AutoOps hangs vehicles off customers | Canonical model keeps an opaque externalRef per entity; adapters own the mapping. Vertical-flavored extensions (vehicle for auto) rather than one universal schema. |
| Capability gaps | AutoOps can't cancel SMS-typed customers (permanent policy); minimal CRMs lack availability | Capability flags, not silent failure. The workflow already needs this concept; today it's encoded implicitly in which branch exists. |
| Protocol diversity | REST, GraphQL (Jobber), browser automation (AccuLynx/ServPro via Playwright) | Adapters hide transport. Browser adapters get reduced capability flags and honest latency expectations. |
| Per-client variants | Salesforce Kelso and Omnia are per-tenant customizations | Adapters can be per-tenant where needed; the interface doesn't care. This bounds proliferation instead of preventing it, which is the honest best case. |
| CRM-specific richness | ST writeback, custom fields, memberships, campaigns | Escape hatch: adapters may expose CRM-specific extensions; workflow code that uses one declares the dependency explicitly instead of hiding it in a branch. |
Commercial precedent that this works at category scale: unified-API vendors (Merge, Apideck, Rutter) sell exactly this shape (one canonical interface, per-provider adapters, capability flags) across categories broader than FSM/CRM.
Verdict: yes for the core, with honesty flags. The seven-op core plus capability flags plus a declared escape hatch covers the overwhelming share of call outcomes. The comprehensive-mapping question resolves to "comprehensive for Avoca's closed usage surface," which the inventory shows is already true in practice, just not in code.
The testing payoff (why this belongs in the testing regime)
- Post-call becomes blackbox-testable, once. Feed a stored end-of-call payload at the post-call entry seam, run against the reference implementation, assert on canonical ops: "this call produced
book(customer X, slot Y)." One test corpus covers every CRM's workflow logic, because the workflow no longer contains CRM-specific logic. - The conformance suite defines what a CRM integration is. New CRM = write an adapter + pass the conformance tests (same suite, run against the adapter + a paid test instance). Integration quality stops being vibes.
- Mocking collapses. The reference implementation replaces N per-CRM mocks. The WireMock layer remains useful for one thing only: adapter conformance against recorded real traffic.
- The capability matrix feeds the configuration audit. "This team's expectations say cancels, this CRM adapter says
supportsCancel: false" is a config-audit finding generated mechanically instead of by an LLM guessing.
Implementation notes (deliberately light)
Strangler pattern, not a rewrite: define the interface; implement the reference + one adapter (AutoOps is smallest and best understood); route ONE dispatch branch through it; migrate branches opportunistically as they're touched. The 14 stage directories are the evidence this will otherwise keep growing linearly; each new CRM added without the interface deepens the eventual migration.
The dispatch sites to eventually converge: booking-triage.ts#getBookingResult, the per-vertical triager CRM switches, and the non-st-workflow tree.
Open questions
- Whose is it? This is platform-shaped work, bigger than the testing effort that motivates it. It likely needs its own owner and sign-off path even if the testing proposal is what surfaces it.
- Does ST writeback/crm-sync (bidirectional) fit behind the interface, or stay explicitly outside it as an ST-specific subsystem? (Recommendation: outside, via the escape hatch, at least initially.)
- Per-tenant Salesforce variants: adapter-per-tenant or config-parameterized adapter?
- The minimal-tier CRMs (Oasis, Albiware, Dynamics 365, HubSpot): verify each one's actual op surface before claiming coverage; some may be lead-capture-only, which is fine (capability flags all false except customer ops) but should be stated, not discovered.