Appearance
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
| Field | Value |
|---|---|
| Market position | Second-largest CRM in the HVAC/plumbing space in the US (after ServiceTitan) |
| PR | AvocaAI/avoca-next#10249 |
| Author | shaharyar-avoca |
| State | OPEN, awaiting review |
| Lines | +820 / -0 |
| Files | 4 new, 1 modified |
| Modified files | run-triage-home-service.ts (+62 / -0) |
| Existing CRMs touched | None (byte-identical paths for ServiceTitan, ALBIWARE, FOUR_SEASONS) |
| Off-ramp | Per-team config flip back to legacy webhook |
| Tests | None 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
applyTechReassignmentOnReschedulein 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_endper-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:
- If the job type isn't in the config, the agent can't book it.
- If the technician-job-type mapping is wrong, technician resolution fails or assigns the wrong tech.
- 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 blockBoth webhooks call the same leaves. The migration moves orchestration; action logic is unchanged.
File 1: hcp-context.ts
Owns the shared bits: types, prepareHcpContext, runHcpPostCall, two small helpers.
prepareHcpContext(ctx, context)
Does four things:
- Builds
HouseCallProConfigviabuildHCPConfig(teamId, ctx.report.vapi?.assistantId) - Extracts call details via
getHCPCallDetails(teamId, transcript, context, callConversationId). Returnsnullearly if no details (logged as error). - Deterministic slot resolution: if booking windows were used during the call AND
callDetails.selected_time_slot_idis missing, queriesfetched_availabilitiesfor slot contexts and tries to resolve the slot id. Mirrors the legacyhcpWorkflowbehavior verbatim. Wrapped in its own try/catch — failure logs a warn and proceeds without the slot id. - Instantiates
new HouseCallPro(config.apiKey)and grabs the phone number fromctx.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 valuebuildFormattedCallFrom(phoneNumber: string): string— wrapsformatPhoneNumber
File 2: run-booking-hcp.ts
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 viacreateHCPCustomerFromCallDetails. 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: falseand no booking - Still runs
runHcpPostCall— notifier still fires even on non-service calls
Service-required path
Lookup → decide → book:
- Customer resolve via
hcpCustomerResolver. Throws if!phoneNumber. - Override
callDetails.is_new_customerwith the system lookup result (more reliable than LLM extraction — mirrors legacy). - Decide job vs estimate:typescriptPriority order:
const shouldBookEstimate = config.alwaysBookJob ? false : config.alwaysBookEstimate || callDetails.is_estimate;alwaysBookJob > alwaysBookEstimate > LLM is_estimate. Verbatim copy from legacy. - Book via
hcpEstimateBookerorhcpJobBooker. - 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
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 viacreateHCPCustomerFromCallDetails - 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
Handles the rescheduling outcome type. Three paths.
Missing required fields path (fallback)
- Required fields:
reschedule_job_idandreschedule_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
- Call
hcpJobRescheduler({ jobId, newStartTime: newStart.toISO(), newEndTime: newEnd.toISO(), teamId, callId }) - 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'. - Build
bookingResultwith 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 type | HCP path |
|---|---|
booking | ✅ runHcpBookingWorkflow (default case) |
message | ✅ runHcpBookingWorkflow (default case — info-only call still creates contact if configured) |
rescheduling | ✅ runHcpReschedulingWorkflow |
cancellation | ✅ runHcpCancellationWorkflow |
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:
| Field | Effect |
|---|---|
vertical = 'HOME_SERVICES' (or unset) | Routes to HomeServicesWorkflowRunTriager |
crm = 'HOUSECALL_PRO' | Activates the HCP dispatch block |
create_customer_if_not_found: boolean | Gates contact creation on non-service / cancel-missing-id / reschedule-missing-fields paths |
alwaysBookJob: boolean | Forces job booking (overrides estimate) |
alwaysBookEstimate: boolean | Forces estimate (lower priority than alwaysBookJob) |
oncall_enabled && oncall_calendar_v2_enabled | Gates on-call v2 dispatch in runHcpPostCall |
system_prompt | Passed to extractActionItems |
action_required_guidance | Passed 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 block | Lines | New file |
|---|---|---|
| Prelude (callDetails extraction, slot resolution, HCP client init) | 30-103 | hcp-context.ts::prepareHcpContext |
if (callDetails.is_cancellation) { ... } | 105-177 | run-cancellation-hcp.ts |
if (callDetails.is_rescheduling) { ... } | 180-294 | run-rescheduling-hcp.ts |
if (callDetails.service_yes_or_no !== 'Yes') { ... } (non-service path) | 297-334 | run-booking-hcp.ts (first branch) |
| Service-required + booking path | 337-464 | run-booking-hcp.ts (second branch) |
(caller orchestrates) postCallProcess + notifier + oncallV2 | n/a (lives in hcpBookingHandler) | hcp-context.ts::runHcpPostCall |
What the migration adds beyond the structural split:
- Per-stage error isolation in
runHcpPostCall(legacyhcpBookingHandlerdoesn't wrappostCallProcessin try/catch — failure there throws pastnotifier) - Structural separation: outcome classification moves from the leaf (LLM
is_cancellationflag) to the triager (rowtype === '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:
| Smell | Where | Why it matters |
|---|---|---|
| Empty-string fallbacks | callId: callId ?? '' and callConversationId: conversationId ?? '' in the dispatch block | Falsey 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 books | default: 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 runHcpPostCall | Each post-call stage logs-and-continues with no surfaced signal | If notifier breaks for all migrated HCP teams, Datadog noise is the only signal. No notifier_sent field on the outcome row. |
| Slot resolution duplicated | prepareHcpContext re-implements hcpWorkflow's slot resolution verbatim | Drift risk when one path changes and the other doesn't — flag for reconciliation when legacy webhook sunsets |
new HouseCallPro(config.apiKey) per workflow call | Each of the three workflow files creates its own client | Minor allocation. Copy-paste tax illustrative of the broader adapter pattern smell. |
extractActionItems outside try/catch | runHcpPostCall | If action-items extraction throws, postCallProcess + notifier + oncallV2 all skip. Possibly intentional (action items feed notifier) but worth confirming. |
| No tests | booking-hcp/__tests__/ doesn't exist | Hamming + manual smoke are the safety net. PR's "Test Plan" section is blank. |
Rollout plan (per-team)
Two flips per team:
- Vapi server URL flip from
/api/responder/non-st-crm/hcp/webhookto/api/responder/common/workflow responder_webhook_configsupdate: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_guidanceconfig been validated for each migrating team? These feed the action-items extractor inrunHcpPostCall; missing or wrong config could change notifier output. - Should the slot-id resolution duplication be resolved now or after sunset? Currently both legacy
hcpWorkflowand newprepareHcpContexthave copy-pasted slot resolution. If they drift, the migrated team sees different behavior than the un-migrated team.
Cross-references
- The migration playbook (generic recipe):
../migration-playbook.md - The system this migrates to:
../architecture.md - The system this migrates from:
../legacy-comparison.md - Improvement arguments seeded by this PR:
../future-brief.md - PR on GitHub: AvocaAI/avoca-next#10249