Skip to content

HouseCall Pro (HCP) migration

The worked example for the migration playbook. HouseCall Pro is mid-migration as of 2026-05-12: PR AvocaAI/avoca-next#10249 is the work-in-progress (820 additions, 0 deletions, 0 modifications). This page walks the PR end-to-end with annotations.

How to read this

If you want to understand the migration shape, read this page after the architecture and legacy-comparison pages. If you're about to do a different CRM migration, read this as the concrete reference while following the playbook.

Unsolicited Opinion

HCP is the canonical "wrapping a legacy CRM" migration. Every CRM on the backlog (acculynx, clypboard, fieldRoutes, iaa, jobNimbus, jobber, pestPac, servpro, workiz) will follow this shape, not the oasis shape. The PR description claims oasis as the precedent — that's structurally misleading. Oasis owns its leaves under lib/oasis/ (greenfield); HCP wraps legacy leaves under lib/non-st-workflow/hcp/utils/ (migration). The argument-shape and the file-shape both differ.


Status snapshot

FieldValue
Market positionSecond-largest CRM in the HVAC/plumbing space in the US (after ServiceTitan)
PRAvocaAI/avoca-next#10249
Authorshaharyar-avoca
StateOPEN, awaiting review
Lines+820 / -0
Files4 new, 1 modified
Modified filesrun-triage-home-service.ts (+62 / -0)
Existing CRMs touchedNone (byte-identical paths for ServiceTitan, ALBIWARE, FOUR_SEASONS)
Off-rampPer-team config flip back to legacy webhook
TestsNone added (Hamming + manual smoke + Datadog are the safety net)

HCP-specific API constraints

Two structural facts about HCP's API shape the data architecture in ways that aren't immediately obvious from reading the migration PR. Both are documented here because they explain why HCP needs more configuration than ServiceTitan.

Constraint 1: No runtime API for job types

HCP doesn't expose an API to list a team's job types at request time. ServiceTitan does. So for HCP:

  • Job types are captured during customer onboarding (manual or admin-portal driven) and stored in the team's HCP config row in our DB.
  • The booking workflow reads job types from the config, not from the HCP API.
  • If a team adds a new job type in HCP, an Avoca operator (or the team via the admin portal) has to mirror it into the config table for the agent to know about it.

Constraint 2: No runtime API for technicians

Same shape. ServiceTitan exposes technician listings; HCP doesn't. So for HCP:

  • Technicians are stored in the team's HCP config row, captured at onboarding.
  • Avoca maintains a technician → job-type mapping in the config so the technician resolver knows which technicians can handle which job types (since HCP can't tell us at runtime).
  • This mapping is the input to applyTechReassignmentOnReschedule in the reschedule workflow.

Constraint 3: HCP's scheduling engine is technician-calendar-driven, not company-calendar

HCP schedules jobs against individual technician calendars rather than a company-wide capacity calendar. ServiceTitan supports both models; HCP only supports the per-technician one. This means:

  • Capacity planning for HCP is "which technicians have which slots available," not "how many slots does the shop have at 2pm."
  • A booking decision involves picking a technician AND a slot, not just a slot.
  • This is why HCP has service_start / service_end per-technician fields in its config (rather than per-team).

Why these constraints matter for the migration

These constraints are why HCP has its own config table (house_call_pro_config with API key, service hours, job duration, "day to fetch", same-day booking flag, job types array, technician array, technician-job-type mapping). The migration PR doesn't change any of this — it inherits the legacy config builder (buildHCPConfig) wholesale. But anyone debugging "why isn't this booking working?" needs to know that:

  1. If the job type isn't in the config, the agent can't book it.
  2. If the technician-job-type mapping is wrong, technician resolution fails or assigns the wrong tech.
  3. Reschedule's tech-reassignment depends on the same mapping.

For migration of any other CRM with similar API limitations (Field Routes, Job Nimbus likely), expect the same shape: per-team config table, onboarding-time population, per-CRM scheduling model embedded in the config.


File layout

apps/web/lib/workflow/stages/booking-hcp/
├── hcp-context.ts                 220 lines  — shared types, prepareHcpContext, runHcpPostCall, helpers
├── run-booking-hcp.ts             187 lines  — booking + non-service paths
├── run-cancellation-hcp.ts        143 lines  — cancellation path
└── run-rescheduling-hcp.ts        208 lines  — rescheduling path

apps/web/lib/workflow/stages/run/
└── run-triage-home-service.ts     +62 lines  — HOUSECALL_PRO dispatch block

Both webhooks call the same leaves. The migration moves orchestration; action logic is unchanged.


File 1: hcp-context.ts

Source

Owns the shared bits: types, prepareHcpContext, runHcpPostCall, two small helpers.

prepareHcpContext(ctx, context)

Does four things:

  1. Builds HouseCallProConfig via buildHCPConfig(teamId, ctx.report.vapi?.assistantId)
  2. Extracts call details via getHCPCallDetails(teamId, transcript, context, callConversationId). Returns null early if no details (logged as error).
  3. Deterministic slot resolution: if booking windows were used during the call AND callDetails.selected_time_slot_id is missing, queries fetched_availabilities for slot contexts and tries to resolve the slot id. Mirrors the legacy hcpWorkflow behavior verbatim. Wrapped in its own try/catch — failure logs a warn and proceeds without the slot id.
  4. Instantiates new HouseCallPro(config.apiKey) and grabs the phone number from ctx.report.caller?.number.

Returns { config, callDetails, hcp, phoneNumber } or null.

runHcpPostCall(ctx, config, callDetails, bookingResult, context)

Orchestrates the post-call stages with per-stage error isolation:

typescript
const actionItems = await extractActionItems(...);   // outside try/catch — failure throws
try { await postCallProcess(...); } catch { log.error('...continuing to notifier'); }
try { await notifier(...); notificationSent = true; } catch { log.error('...'); }
if (config.typedConfig?.oncall_enabled && config.typedConfig?.oncall_calendar_v2_enabled) {
  try { await oncallV2AdapterForNonST(...); } catch { log.error('...'); }
}
return notificationSent;

Notice extractActionItems is NOT wrapped in try/catch — if action-items extraction throws, the entire post-call function bails. The legacy hcpBookingHandler has the same behavior. (Could be intentional — action items feed the notifier — or could be a smell. Flagged for the future-brief.)

Helpers

  • failurePreparedContextResult(error: string): HcpWorkflowResult — builds the standard "no call details" return value
  • buildFormattedCallFrom(phoneNumber: string): string — wraps formatPhoneNumber

File 2: run-booking-hcp.ts

Source

Handles the booking and message outcome types. Two paths:

Non-service / info-only path (service_yes_or_no !== 'Yes')

Mirrors legacy behavior:

  • Logs the non-service reason
  • If config.typedConfig?.create_customer_if_not_found: persists the caller as an HCP contact via createHCPCustomerFromCallDetails. Wrapped in try/catch — failure logs 'createHCPCustomerFromCallDetails failed on HCP service-not-required path; post-call processing and notifier will still run'.
  • Returns success with bookingConfirmed: false, serviceRequired: false and no booking
  • Still runs runHcpPostCall — notifier still fires even on non-service calls

Service-required path

Lookup → decide → book:

  1. Customer resolve via hcpCustomerResolver. Throws if !phoneNumber.
  2. Override callDetails.is_new_customer with the system lookup result (more reliable than LLM extraction — mirrors legacy).
  3. Decide job vs estimate:
    typescript
    const shouldBookEstimate = config.alwaysBookJob
      ? false
      : config.alwaysBookEstimate || callDetails.is_estimate;
    Priority order: alwaysBookJob > alwaysBookEstimate > LLM is_estimate. Verbatim copy from legacy.
  4. Book via hcpEstimateBooker or hcpJobBooker.
  5. Run post-call via runHcpPostCall.

Failure mode: any throw inside the try/catch sets bookingResult.success: false with the error message, then post-call still runs.


File 3: run-cancellation-hcp.ts

Source

Handles the cancellation outcome type. Two paths:

Missing cancel_job_id path (fallback)

  • Logs warn 'Cancellation outcome but no cancel_job_id extracted'
  • If create_customer_if_not_found: persists contact via createHCPCustomerFromCallDetails
  • Returns success: false, error: 'Cancellation detected but no job ID could be extracted'
  • Runs post-call regardless

Cancel path

Calls hcpJobCanceller. Success criteria:

typescript
const success = cancellationResult.alreadyTerminal
  || (cancellationResult.unscheduled && cancellationResult.unassigned);

Where alreadyTerminal means the job was already in a terminal state (already cancelled, completed, etc.); unscheduled && unassigned means the cancel actively worked. Either is treated as success.

Failure mode: hcpJobCanceller throws → log + push error + set bookingResult.success: false, error: 'hcpJobCanceller threw'. Post-call still runs.


File 4: run-rescheduling-hcp.ts

Source

Handles the rescheduling outcome type. Three paths.

Missing required fields path (fallback)

  • Required fields: reschedule_job_id and reschedule_new_time
  • Logs warn, optional contact-create, returns success: false, error: 'Rescheduling detected but missing job ID or new time'
  • Runs post-call

Time parse failure path

typescript
const timezone = config.timezone || 'America/New_York';
const jobDuration = config.jobDuration || 2;
const newStart = DateTime.fromFormat(callDetails.reschedule_new_time, 'MM/dd/yyyy hh:mm a', { zone: timezone });
if (!newStart.isValid) { return parse-failure result; }
const newEnd = newStart.plus({ hours: jobDuration });

If the LLM-extracted time string isn't MM/dd/yyyy hh:mm a, returns success: false, error: 'Failed to parse rescheduling time: <raw>'. Post-call still runs.

Reschedule + tech-reassignment path

  1. Call hcpJobRescheduler({ jobId, newStartTime: newStart.toISO(), newEndTime: newEnd.toISO(), teamId, callId })
  2. If reschedule succeeded: call applyTechReassignmentOnReschedule(...). Tech-reassignment failure does NOT roll back the reschedule — wrapped in its own try/catch with detail 'applyTechReassignmentOnReschedule failed after successful HCP reschedule; reschedule remains committed in HCP and post-call processing + notifier will still run'.
  3. Build bookingResult with the reschedule details (oldStart/newStart/oldEnd/newEnd).

This is structurally important: a partial-success state exists where the job is rescheduled in HCP but the tech-reassignment didn't happen. The notifier and post-call still fire, so the ops team gets a notification, but the underlying HCP state could be unexpected.


File 5 (modified): run-triage-home-service.ts

Source — modified lines — +62 / -0

The HOUSECALL_PRO dispatch block. Placed after the ALBIWARE early-return (line 254 area) and before the main outcome-type switch (line 267 area). Verbatim:

typescript
if (typedConfig.crm === 'HOUSECALL_PRO') {
  const errors: { error: unknown }[] = [];
  const hcpCtx: HcpCallContext = {
    teamId,
    callId: callId ?? '',
    callConversationId: conversationId ?? '',
    transcript: this.transcript,
    report,
    assistantName: report.assistantName ?? '',
    callType,
    isOutbound: report.isOutbound,
    guidance: row.guidance,
  };
  try {
    let result;
    switch (row.type) {
      case 'rescheduling':
        result = await runHcpReschedulingWorkflow(hcpCtx, this.context);
        break;
      case 'cancellation':
        result = await runHcpCancellationWorkflow(hcpCtx, this.context);
        break;
      case 'eta':
      case 'job-notes':
      case 'confirmed-appointment':
      case 'update-customer-info':
        this.log.info(
          `HCP does not support outcome '${row.type}', skipping`,
          { outcomeId: row.id, outcomeType: row.type }
        );
        result = { skipped: true, reason: `HCP does not support outcome '${row.type}'` };
        break;
      default:
        // 'booking' and 'message' both route through the booking workflow,
        // matching the Flooring/Oasis precedent.
        result = await runHcpBookingWorkflow(hcpCtx, this.context);
    }
    return { results: [{ ...result, outcome: row }], errors };
  } catch (error) {
    this.log.error(/* ... */);
    errors.push({ error });
    return { results: [], errors };
  }
}

This matches the retry contract: on exception → {results: [], errors} → row stays pending. On success → {results: [{ ...result, outcome: row }], errors: []} → row marked executed.


Outcome type support

HCP supports four of the seven HOME_SERVICES outcome types:

Outcome typeHCP path
bookingrunHcpBookingWorkflow (default case)
messagerunHcpBookingWorkflow (default case — info-only call still creates contact if configured)
reschedulingrunHcpReschedulingWorkflow
cancellationrunHcpCancellationWorkflow
eta❌ no-op + log line
job-notes❌ no-op + log line
confirmed-appointment❌ no-op + log line
update-customer-info❌ no-op + log line

The "unsupported" set is encoded as switch cases that return {skipped: true, reason: ...}. Per the retry contract, skipped results are treated as success — the row gets marked executed, no retry.


Pre-conditions in typedConfig

The dispatch block depends on these responder_webhook_configs columns / typedConfig fields:

FieldEffect
vertical = 'HOME_SERVICES' (or unset)Routes to HomeServicesWorkflowRunTriager
crm = 'HOUSECALL_PRO'Activates the HCP dispatch block
create_customer_if_not_found: booleanGates contact creation on non-service / cancel-missing-id / reschedule-missing-fields paths
alwaysBookJob: booleanForces job booking (overrides estimate)
alwaysBookEstimate: booleanForces estimate (lower priority than alwaysBookJob)
oncall_enabled && oncall_calendar_v2_enabledGates on-call v2 dispatch in runHcpPostCall
system_promptPassed to extractActionItems
action_required_guidancePassed to extractActionItems

The cancellations_enabled / rescheduling_enabled / update_eta_calls_job_summary / etc. feed determineWorkflowOutcomes upstream — gating which outcome rows get created, not what happens once a row exists.


Legacy → new section mapping

Side-by-side of the legacy hcpWorkflow (440 lines, single function) and the new files:

Legacy hcpWorkflow blockLinesNew file
Prelude (callDetails extraction, slot resolution, HCP client init)30-103hcp-context.ts::prepareHcpContext
if (callDetails.is_cancellation) { ... }105-177run-cancellation-hcp.ts
if (callDetails.is_rescheduling) { ... }180-294run-rescheduling-hcp.ts
if (callDetails.service_yes_or_no !== 'Yes') { ... } (non-service path)297-334run-booking-hcp.ts (first branch)
Service-required + booking path337-464run-booking-hcp.ts (second branch)
(caller orchestrates) postCallProcess + notifier + oncallV2n/a (lives in hcpBookingHandler)hcp-context.ts::runHcpPostCall

What the migration adds beyond the structural split:

  • Per-stage error isolation in runHcpPostCall (legacy hcpBookingHandler doesn't wrap postCallProcess in try/catch — failure there throws past notifier)
  • Structural separation: outcome classification moves from the leaf (LLM is_cancellation flag) to the triager (row type === 'cancellation')

What the migration doesn't change:

  • All leaf functions (hcpJobBooker, hcpJobCanceller, etc.)
  • The success-criteria logic in each path
  • The job-vs-estimate priority chain
  • The fallback-to-contact-creation behavior on missing-data paths
  • The slot-id resolution logic (verbatim copy, will need reconciliation when legacy sunsets)

Smells specific to this PR

From the PR review:

SmellWhereWhy it matters
Empty-string fallbackscallId: callId ?? '' and callConversationId: conversationId ?? '' in the dispatch blockFalsey checks downstream still work, but it's not fail-fast. The prepareHcpContext slot-resolution block has if (callConversationId) which treats '' as "skip slot resolution" — possibly intentional but worth verifying.
Default-case silently booksdefault: result = await runHcpBookingWorkflow(...)Typo or new outcome type added upstream without updating downstream branches will silently book. Mitigated only by the explicit enumeration of unsupported types.
Error swallowing in runHcpPostCallEach post-call stage logs-and-continues with no surfaced signalIf notifier breaks for all migrated HCP teams, Datadog noise is the only signal. No notifier_sent field on the outcome row.
Slot resolution duplicatedprepareHcpContext re-implements hcpWorkflow's slot resolution verbatimDrift risk when one path changes and the other doesn't — flag for reconciliation when legacy webhook sunsets
new HouseCallPro(config.apiKey) per workflow callEach of the three workflow files creates its own clientMinor allocation. Copy-paste tax illustrative of the broader adapter pattern smell.
extractActionItems outside try/catchrunHcpPostCallIf action-items extraction throws, postCallProcess + notifier + oncallV2 all skip. Possibly intentional (action items feed notifier) but worth confirming.
No testsbooking-hcp/__tests__/ doesn't existHamming + manual smoke are the safety net. PR's "Test Plan" section is blank.

Rollout plan (per-team)

Two flips per team:

  1. Vapi server URL flip from /api/responder/non-st-crm/hcp/webhook to /api/responder/common/workflow
  2. responder_webhook_configs update: crm = 'HOUSECALL_PRO', vertical = 'HOME_SERVICES'

The PR description doesn't list specific pilot teams; expect customer-ops to roll forward team-by-team with monitoring between each.

Off-ramp: flip both back. The legacy webhook continues to serve un-migrated HCP teams indefinitely.


Open questions for HCP

  • Which team is the migration pilot? Worth confirming so the validation Hamming sessions target a known team's data.
  • Has the system_prompt + action_required_guidance config been validated for each migrating team? These feed the action-items extractor in runHcpPostCall; missing or wrong config could change notifier output.
  • Should the slot-id resolution duplication be resolved now or after sunset? Currently both legacy hcpWorkflow and new prepareHcpContext have copy-pasted slot resolution. If they drift, the migrated team sees different behavior than the un-migrated team.

Cross-references