← All demo builds
Demo build · sample dataServiceTitan API · Zapier · Claude

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.

About this demo: client systems live in client accounts, so this is rebuilt on synthetic data shaped like ServiceTitan API responses. The workflow design, code, prompt and schema below are the real components. No numbers on this page are a client's.

What the manager sees

OB
Ops Briefing 6:00 AM · sample data
Yesterday, Tue Oct 27 · 31 jobs completed · revenue $24,860 · average ticket $802 · 4 memberships sold
Needs attention (3) • 2 unsold estimates over $8,000 with no follow-up scheduled • 1 call flagged "unhappy": customer waited 3 hours past the arrival window • Technician #14 has 3 callbacks this week
Replacement opportunities (5 new) • 1418 Maple Ct · furnace age 19 yrs · 3 repair visits in 12 months · score 75 Customer said: “honestly I'm wondering if it's time to just replace the whole thing” • 77 Birch Rd · AC uses R-22 refrigerant · repair quote 41% of replacement · score 60
Full list in the Replacement Tracker sheet · 0 errors in this run

How it works

  1. 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.
  2. Authenticate. Webhooks by Zapier requests an access token with client credentials; every API call carries the token, the app key and the tenant ID.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
CodeAIHumanData
Execution replay of the design, not live data

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.

DataServiceTitan API module › resourceUsed for
Tokenauth.servicetitan.io/connect/tokenClient-credentials access token
Jobsjpm › jobsCompleted jobs, totals, technicians, callbacks
Callstelecom › callsCall records and transcripts for the AI step
Estimatessales › estimatesUnsold estimates and their follow-up status
Membershipsmemberships › membershipsNew and expiring memberships
Equipmentequipmentsystems › installed-equipmentInstall 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.

Code by Zapier · JavaScriptreplacement_score.js
// 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.

System prompt (excerpt)
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".
Output schema
{
  "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

Code by Zapier · JavaScriptvalidate_signal.js
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.

Call transcript (sample)
AI step output

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)ScoreReasons from codeCall evidenceStatus
1418 Maple Ct75furnace age 19 yrs; 3 repair visits in 12 months“wondering if it's time to just replace the whole thing” (verified)Assign
77 Birch Rd60uses R-22; repair quote 41% of replacement; age 14 yrsnoneAssign
9 Harbour St35age 13 yrs; 2 repair visitspossible (quote not found, downgraded)Watch

Running ServiceTitan and want answers by 6 am?

Message me on Upwork