A daily ops briefing and replacement-opportunity tracker for an HVAC and plumbing company
Every morning at 6:00, managers get one Slack message: yesterday's numbers, calls that need follow-up, and homes where the equipment is due for replacement, each with the evidence behind it. Code calculates every number. AI only reads call transcripts, and every claim it makes has to quote the transcript word for word.
What the manager sees
How it works
- Schedule (6:00 am). Zapier's schedule trigger starts the run. A storage key holds the last successful run date so a replay can't double-post.
- Authenticate. Webhooks by Zapier requests an access token with client credentials; every API call carries the token, the app key and the tenant ID.
- Pull yesterday's data. Paginated calls for completed jobs, calls with transcripts, open estimates, memberships and installed equipment. A 429 or 5xx response is retried with backoff, then routed to the error path.
- Code step: numbers and rules. JavaScript computes revenue, average ticket, unsold estimates and a replacement score from equipment age, repair history, refrigerant type and repair-to-replace cost ratio. No AI touches a number.
- AI step: read the calls. Claude classifies each transcript and returns JSON only: intent, sentiment, replacement interest, follow-up needed and an exact quote as evidence.
- Validator. Checks the schema and confirms each quote exists in the transcript. Anything unverifiable is downgraded, not passed through. Two failures go to an error channel with a log row.
- Merge and deliver. Facts and verified signals become the Slack briefing and new rows in the tracker, deduplicated by service location. A manager assigns follow-ups.
The API calls
Every call carries an OAuth access token and the ST-App-Key header, and is scoped to the company's tenant ID.
| Data | ServiceTitan API module › resource | Used for |
|---|---|---|
| Token | auth.servicetitan.io/connect/token | Client-credentials access token |
| Jobs | jpm › jobs | Completed jobs, totals, technicians, callbacks |
| Calls | telecom › calls | Call records and transcripts for the AI step |
| Estimates | sales › estimates | Unsold estimates and their follow-up status |
| Memberships | memberships › memberships | New and expiring memberships |
| Equipment | equipmentsystems › installed-equipment | Install dates, type and refrigerant for the replacement score |
Code step: the replacement score
A rule a manager can read and argue with. AI never decides this number.
// Score 0–100 per service location. Every point has a stated reason. const RULES = [ { id: 'age', pts: e => e.ageYears >= 15 ? 35 : e.ageYears >= 12 ? 20 : 0, why: e => `equipment age ${e.ageYears} yrs` }, { id: 'repairs', pts: e => e.repairs12mo >= 3 ? 25 : e.repairs12mo === 2 ? 15 : 0, why: e => `${e.repairs12mo} repair visits in 12 months` }, { id: 'r22', pts: e => e.refrigerant === 'R-22' ? 20 : 0, why: () => 'uses R-22 refrigerant' }, { id: 'costRatio', pts: e => e.repairQuote / e.replaceEstimate >= 0.4 ? 20 : 0, why: e => `repair quote ${Math.round(100 * e.repairQuote / e.replaceEstimate)}% of replacement` }, ]; function scoreLocation(equipment, callSignal) { let score = 0; const reasons = []; for (const r of RULES) { const p = r.pts(equipment); if (p > 0) { score += p; reasons.push(r.why(equipment)); } } // A verified "strong" interest from a call adds weight, but only with evidence. if (callSignal?.replacement_interest === 'strong' && callSignal.evidence_verified) { score += 15; reasons.push('customer raised replacement on a call'); } return { score: Math.min(score, 100), reasons }; }
AI step: prompt and output contract
The model gets one transcript at a time and may only answer in this shape. Numbers, prices and dates are not its job.
You review one phone call transcript for a residential HVAC and plumbing company. Return JSON only, matching the schema. No prose. Rules: - Quote the customer's exact words in evidence_quote. Copy them character for character. - If the transcript does not clearly support a field, use "unknown" or null. Never guess. An empty answer is better than an invented one. - Do not state prices, dates, equipment ages or counts. Code handles those. - replacement_interest is "strong" only if the customer raises replacing equipment themselves. A technician suggesting it is "possible".
{
"call_id": "string",
"intent": "booking | complaint | estimate_question | billing | other",
"sentiment": "positive | neutral | unhappy",
"replacement_interest": "none | possible | strong | unknown",
"follow_up_needed": true,
"follow_up_reason": "string or null",
"evidence_quote": "exact words from the transcript, or null"
}
Validator: no quote, no claim
const ALLOWED = { intent: ['booking', 'complaint', 'estimate_question', 'billing', 'other'], sentiment: ['positive', 'neutral', 'unhappy'], replacement_interest: ['none', 'possible', 'strong', 'unknown'], }; const norm = s => String(s || '').toLowerCase().replace(/[“”"]/g, '"').replace(/[‘’]/g, "'").replace(/\s+/g, ' ').trim(); function validate(raw, transcript) { let out; try { out = JSON.parse(String(raw).replace(/^```(json)?|```$/g, '')); } catch (e) { return { ok: false, error: 'not valid JSON' }; } for (const [field, values] of Object.entries(ALLOWED)) { if (!values.includes(out[field])) return { ok: false, error: `${field} must be one of ${values.join(', ')}` }; } // The quote must really be in the transcript. Otherwise the claim is downgraded. out.evidence_verified = !!out.evidence_quote && norm(transcript).includes(norm(out.evidence_quote)); if (!out.evidence_verified && out.replacement_interest === 'strong') out.replacement_interest = 'possible'; return { ok: true, signal: out }; } // On ok:false the Zap re-asks the model once with the exact error, then routes to the error path.
Try the validator
Pick a sample call and run it. The AI output is pre-written for the demo, but the check runs live in your browser using the same rules as the code above.
Reliability built in
Rate limits
Paginated pulls with a delay between pages. A 429 or 5xx waits and retries, and after the retry limit the run goes to the error path instead of failing silently.
No double posts
The last successful run date is stored. A replayed or manual run for the same date updates the tracker instead of posting a second briefing.
Deduplicated tracker
Rows are keyed by service location, so the same home is updated with new evidence instead of appearing twice.
Visible failures
Every error writes a log row with the step, the input ID and the message, and posts to an ops channel. A run with errors says so in the briefing footer.
Sample tracker rows
| Location (sample) | Score | Reasons from code | Call evidence | Status |
|---|---|---|---|---|
| 1418 Maple Ct | 75 | furnace age 19 yrs; 3 repair visits in 12 months | “wondering if it's time to just replace the whole thing” (verified) | Assign |
| 77 Birch Rd | 60 | uses R-22; repair quote 41% of replacement; age 14 yrs | none | Assign |
| 9 Harbour St | 35 | age 13 yrs; 2 repair visits | possible (quote not found, downgraded) | Watch |