Skip to content

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 on typedConfig.crm selecting bookJobInST, 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:

  1. 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.
  2. Per-CRM adapters wrapping the existing client libraries. The clients don't get rewritten; they get fronted.
  3. 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:

TierCRMsCharacter
DeepServiceTitan (17 generated API modules, writeback, crm-sync), AutoOps, Jobber (GraphQL, full ops)Full clients, multiple ops, live post-call stages
MidHousecall Pro, PestPac, FieldRoutes, Four Seasons, Neighborly, Salesforce (+ Kelso and Omnia per-client variants)Working clients, narrower op surfaces
MinimalFieldEdge, Oasis, Albiware, Dynamics 365, HubSpot, JobNimbusPartial or single-purpose
Browser-automatedAccuLynx, ServPro (Playwright)No API; driven through the UI
No integrationNO_CRM teamsWorkflow-only (messages, emails)

The operation surfaces converge

The three deepest integrations, side by side:

Canonical opAutoOpsJobberServiceTitanHCP
search customersearchCustomer (by phone/id)searchClientByPhoneFindCustomerfind/search customer
create customer(via book)createClientCreateCustomer
get availabilitygetAvailabilitygetScheduledItems (inverse)GetAvailability/GetSchedules
get serviceslistServices(job/quote/request types)GetJobTypes/GetServiceTypes
bookcreateJobcreateJob / createQuote / createRequestCreateJob + BookAppointmentcreate job
cancelcancelJobdeleteVisitCancelJob
reschedulerescheduleJobeditVisitScheduleUpdateJob/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

DivergenceExampleAbsorber
Entity-model mismatchJobber reschedules a visit, not a job; ST splits job vs appointment; AutoOps hangs vehicles off customersCanonical model keeps an opaque externalRef per entity; adapters own the mapping. Vertical-flavored extensions (vehicle for auto) rather than one universal schema.
Capability gapsAutoOps can't cancel SMS-typed customers (permanent policy); minimal CRMs lack availabilityCapability flags, not silent failure. The workflow already needs this concept; today it's encoded implicitly in which branch exists.
Protocol diversityREST, GraphQL (Jobber), browser automation (AccuLynx/ServPro via Playwright)Adapters hide transport. Browser adapters get reduced capability flags and honest latency expectations.
Per-client variantsSalesforce Kelso and Omnia are per-tenant customizationsAdapters 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 richnessST writeback, custom fields, memberships, campaignsEscape 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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.