How the tool is built, where every piece of data comes from, where it's stored, and how the recommendation is calculated. For owners, administrators, and developers.
The system is two parts: a single-file web app that runs in the technician's browser, and one small serverless function that runs on Vercel. The app holds no secrets and talks to outside services only through the function.
The technician's app never contacts RentCast or Anthropic directly, and it never holds an API key. When it needs a lookup, it sends a small request to your Vercel function; the function attaches the secret keys (kept as server environment variables), calls the outside service, and returns clean data. This keeps your billing credentials off every phone and out of the source code.
index.htmlA single self-contained HTML file (markup, styling, and logic in one). It runs entirely in the browser, requires no install, and contains the full survey flow plus the recommendation engine. The recommendation logic is offline — sizing, the Good/Better/Best tiers, and all the flags are computed on the device with no internet call. Only the three lookups (property, nameplate, rebates) reach out.
api/lookup.jsOne Vercel serverless function with a single endpoint that routes on an action field: property, nameplate, rebates, and ping (the health check the app's Test button uses). It is stateless — it stores nothing and has no database. It exists purely to hold the keys and relay calls.
Most of the survey is typed by the technician. Three features pull from outside services; the recommendation is computed locally. The table below maps every data category to its true source.
| Data | Source | How it's obtained |
|---|---|---|
| Customer name, phone, address | Tech entry | Typed by the technician on site. |
| Square footage, year built, stories | RentCast | The backend calls GET /v1/properties?address=. Returns public county / tax-assessor records. Tech can override. Utility provider is not supplied by RentCast — entered by the tech. |
| Brand, model, serial, type, tonnage, age | Anthropic vision | Nameplate photos are sent to the backend, which calls the Anthropic API (vision). The model reads the label and returns structured fields. Age is a best-effort estimate and is flagged for verification. |
| Site conditions, comfort, needs | Tech entry | Tap-to-select on site. |
| Sizing estimate (tons / BTU) | Computed | Calculated on the device from square footage and home age. See §5. |
| Good / Better / Best tiers | Computed | Selected on the device from a built-in equipment table based on the system type. See §5. |
| Opportunity & risk flags | Computed | Rule logic on the device, from the survey answers. See §5. |
| Rebates & incentives | Anthropic + web search | The backend calls the Anthropic API with web search enabled, passing the address, utility, and equipment types. Returns current programs with source links. Always verified by the tech. |
Three distinct places hold data, each with a different scope. Nothing sensitive lives in the app or the source code.
| What | Where | Notes |
|---|---|---|
| Surveys (customer, property, systems, site, needs, generated rebates) | On the technician's device, in the browser's local storage for the app's web address | Per-device and per-browser. Not synced between devices and not sent to a central server. Stored under the key fieldeng_surveys_v2. |
| Photo thumbnails | Same device storage, inside each survey | Only a small downscaled thumbnail (~200px) is kept. The full-resolution image is held in memory only for the session and used once for the AI read — it is never saved. |
| Backend URL & optional app token | Same device storage | Keys fieldeng_backend_url / fieldeng_backend_token. |
| API keys (RentCast, Anthropic) | Vercel environment variables, on the server only | Never in the app, never in the source file, never on a device. Read by the function at runtime. |
| Lookup requests (address, photos) | Transient — passes through the backend to RentCast / Anthropic | The backend itself stores nothing. The third-party services receive the data per their own retention policies (see §6). |
localStorage on the hosted site. Surveys and trouble calls now persist on the device across sessions. Data is still per-device and per-browser — it is not synced to a central server (a future enhancement if you want cross-device history).All of the following runs on the device, with no internet call, so the recommendation works even with no signal.
Per system, using its "area served" if given, otherwise the whole-home square footage when there's only one system:
This is a heuristic for a sanity check and conversation, explicitly labeled in the app as not a Manual J. A full load calculation should confirm it before quoting.
The system type chooses which built-in tier set is shown. There is no live equipment database; these are fixed reference tiers:
| System type | Good | Better | Best |
|---|---|---|---|
| AC / cooling | 14.3 SEER2 single-stage | 16 SEER2 two-stage | 18+ SEER2 inverter |
| Furnace / boiler | 80% AFUE | 96% AFUE | 98% AFUE modulating |
| Heat pump / mini-split | 15 SEER2 / 8.1 HSPF2 | 17 SEER2 / 9 HSPF2 | 19+ SEER2 / 10+ HSPF2 cold-climate |
| AC + Furnace / Package | Shows both a cooling set and a heating set. | ||
Per-system rules: equipment age 15+ years (end-of-life) or 12–14 (aging); condition marked Failed; a gas furnace becomes a heat-pump conversion candidate; and a sizing mismatch fires when the existing tonnage differs from the estimate by 1+ tons. Whole-home rules: a heat-pump recommendation plus a small panel (100A or 125A, or no open breaker spaces) flags a panel upgrade; Poor/Fair ductwork and a line-set marked Replace become scope items; humidity/dust complaints raise an indoor-air-quality opportunity; hot-cold/uneven complaints raise a zoning opportunity; and a maintenance-agreement prompt always appears.
APP_TOKEN environment variable requires every request to send a matching token, blocking casual use of your endpoint by anyone who finds the URL.APP_TOKEN is the appropriate substitute gate.localStorage change noted in §4 before relying on saved surveys.The app now opens on a mode selector — Sales / Survey (sections 01–07) or Tech / Repair. They share the same shell, fonts, backend, storage layer, and AI nameplate read; the repair mode adds a credential-gated diagnostic workflow. This section covers what's specific to it.
Arrive → pick equipment (sets the job-type template) → record reported vs. tech-confirmed complaint → capture suggested components (nameplate read, plus an "other components?" gate) → run the diagnostic loop → repair (if credentials clear it) → verify → stamped job record. Diagnosis is powered by Claude through the backend; gating and safety are enforced in the app.
Each technician has an admin-set profile. Skill gates govern what hands-on work is permitted; experience level (novice/mid/senior/master) tunes guidance verbosity and override eligibility; a separate admin-granted conditional-override flag lets a qualified tech clear specific conditional safety stops with a logged acknowledgment.
| Field | Purpose |
|---|---|
techId | Reference number stamped to every job. |
level | novice / mid / senior / master — tunes how much step detail is given. |
epaType + epaCert | EPA 608 type (II baseline / Universal) and cert number, stamped on refrigerant work. |
gates[] | Skill gates held: EPA-U, E1, E2, E3, G1, G2, HP, B1, B2, WH, CB (OIL reserved, dormant). |
conditionalOverride | May clear conditional hard stops (with acknowledgment). |
G2. Gates are admin-set; a tech cannot self-grant.Authoritatively in the app, not by the model. Claude proposes the next step or repair and tags it with a requiresGate code and flags suspected danger as a hard stop; the app then checks the tech's profile and either allows it, converts it to an escalation (preserving all confirmed work), or halts on the hard stop. Conditional stops are cleared only when the tech holds the required gate and the override flag, and the acknowledgment is written to the record.
Each cycle: the app sends the full context to the backend diagnose action; Claude returns one structured result — a step (title + how + expected reading + caution + requiresGate), a diagnosis (+ gated repair steps), or a hardstop. The tech performs the step, enters the actual measured value (and optional photos + note), and the cycle repeats with the growing reading trail.
Photos can be added at every diagnostic step (and at component capture and verification), not just at nameplates. As elsewhere, only downscaled thumbnails are persisted with the job; full-resolution copies are held in memory for the session and sent once with that step's submission so the model can see what the tech sees. Step photos form part of the training/support record.
| What | Where | Notes |
|---|---|---|
| Tech profile | Device storage fieldeng_tech_profile | Admin-set in production. One profile per device in this first pass. |
| Trouble calls (complaint, components, reading trail, diagnosis, verify, acknowledgments, escalation) | Device storage fieldeng_tech_calls_v1 | Per-device. Photo thumbnails included; full-size never persisted. |
| Job stamps | Within each call record | Tech ID + EPA/credential numbers relevant to the work, plus any override acknowledgments with timestamps. |
| Diagnostic reasoning | Transient — context posted to the backend → Anthropic | The backend stores nothing; readings and any step photos pass through for that call only. |
The quoted system type is one selection that flows through tiers, pricing, rebates, savings, and the proposal. Five types: furnaceac, heatpump (cold-climate), ashp (standard), dualfuel, and ductless (mini-split, multi-zone). Cold-climate and standard are separate types because they earn different per-ton utility rebates. Why ductless: all-electric and electric-baseboard homes have no ductwork, so a ducted system would mean major drywall work; ductless runs on line sets only.
Zone-based pricing. Ductless isn't a single tonnage, so its cost = a baseline (outdoor unit + line sets) + a per-head cost by tier × the zone/head count (DUCTLESS_BASE, DUCTLESS_PER_HEAD, ductlessTierCost()). The head count defaults to ~1 per 500 sq ft and is editable on the recommendation screen (App.setZones), which re-prices live. recommendedType() routes electric baseboard — and electric/propane/oil with ductwork = "none" — to ductless.
utility.gasService ∈ {confirmed, none, unknown}, set on the utility step. Why: gas shown on the utility map ≠ a gas meter at the home (especially 1970s all-electric builds). Effects: none suppresses dual-fuel and gates the Xcel cold-climate rebate — rebatesFromTons() takes the gas-service flag and, when there's no gas, replaces the $/heating-ton cold-climate line with a conservative electric-utility line, so the proposal never shows a rebate the home can't claim. A gas sanity check (computeGasCheck()) reads the fuel-tagged bills: electric bills with no gas bill → "likely no natural gas," with a one-tap "Mark: no gas service," distinguishing all-electric from delivered fuel (propane/oil).
The backend parsebill (vision) reads electric and gas bills from two utilities, returning per-period usage, cost, $/unit rate, peak kW, and average temperature (incl. 12-month history). Bills are fuel-tagged. effectiveFuelPrices() overrides the office's regional rates with the customer's actual $/kWh and $/therm when present, and computeSavings() matches bills to the heating fuel (gas bills drive gas-heated homes, electric bills drive all-electric). Why: the estimate reflects the customer's own bill, not an average — accuracy and trust.
energy.cooling ∈ {central, window, swamp, none}. When it's none or swamp and the recommendation is any heat pump (incl. ductless), an "Adds air conditioning" flag surfaces on the recommendation and carries into the proposal. Why: all-electric, baseboard, and arid-climate homes often have no true AC; the conversion adds whole-home cooling — a comfort upgrade stacked on the heating savings, and a value a like-for-like furnace swap can't offer.
Three paths, all reviewable and overridable:
identify). A batch of label photos (any mix of furnace, AC condenser, heat pump, swamp cooler) is read in one call; the action returns a structured units[] list (de-duplicating photos of the same unit) plus a systemSummary. assembleSystems() turns the units into systems — a furnace + an AC condenser collapse into one furnace_ac system; lone units stand alone — and never overwrites a system already filled in (append-only). Why: the tech just photographs every label; no manual type or slot selection.nameplate). A single label read into one system; mapNpType() auto-selects the dropdown type (furnace→Furnace, condenser→AC, mini-split→Heat pump, gas pack→Furnace+AC), marked "AI-detected — change if wrong."Furnace + AC = two nameplates. A Furnace+AC system holds two label slots (furnace + AC condenser), each with its own photo, AI read, and make/model/serial (the condenser identity lives in sys.ac; AC specs/tons fold into the system). Why: a split system is two physical units and the office needs both identities for ordering and warranty. Evaporative coolers are a recognized existing-equipment type (captured, not sold) so the no-AC / arid-market context is recorded.
Sidekick (Repair) component capture. The repair app types each captured component from a ~13-item taxonomy (furnace, AC condenser, evaporator/indoor coil, heat-pump outdoor, heat-pump indoor/air handler, mini-split outdoor, mini-split head, boiler, water heater, RTU/package, zoning, thermostat, line set, other) with per-type spec fields, and offers a batch “Identify equipment from photos” that reuses the identify action and auto-types each detected unit into a component. To support this, identify returns an additive subtype (e.g. heat_pump_outdoor vs. heat_pump_indoor, evaporator_coil, zoning, rtu) alongside the coarse unit; the Evaluator reads unit and is unaffected, while Sidekick maps subtype→component type. AI-populated components stay editable and carry a “verify” prompt.
Diagnostic step illustrations & hazard graphics. A diagnostic step may include a lookFor {component, location} that renders a schematic SVG illustration of the part with a fixed “illustration only — not an exact match” disclaimer (so it can’t be mistaken for the literal unit), and a safety.hazard that renders a large color-coded hazard banner (line_voltage, low_voltage, capacitor, gas, refrigerant, hot_surface, rotating). Both come from the backend diagnose action and are present in the offline demo. Illustrations are original line-art (no product photos), so there’s no copyright or false-precision risk.
Part lookup, substitutes & photo-verified install. After diagnosis, part_lookup (Claude + web search) returns the required part {type, spec, oemPartNumber, description, costLow/High, laborHours} plus alternatives[], each with a note and an acceptable rating (yes/marginal/no) and the constraint that a substitute must meet the original’s voltage/rating. The app lets the tech select the exact part or an acceptable substitute (it blocks ones rated “no”), and computes a repair estimate (part + labor×rate) that feeds the repair-vs-replace crossover. At close-out, verify_part (vision) reads the installed part’s label and returns status match/workable/mismatch/unreadable; completion is gated — a parted job cannot be stamped done unless verification is match or workable, and a mismatch is either corrected & re-verified or escalated with the read-vs-required discrepancy stamped (job.stamps.installedPart). This is the integrity control against incomplete or misreported part swaps.
Guided fault trees (hybrid). High-frequency tickets (furnace no-heat, AC no-cool) have a declarative tap-driven fault tree (TREES: node-graph of branch / leaf / hard-stop nodes). The diagnose step offers Guided checklist or AI copilot; the walker is deterministic and offline. A node may carry measure (a required reading, logged to the trail before the outcome tap), safety.hazard (big banner) and lookFor (illustration). Leaves are diagnoses with gated repairSteps; an unresolved path or the “unusual” option calls handoffToAI(), which switches to the AI loop and passes the walked path as observations (backend diagnose reads them). Hard stops and credential gating are enforced in the app on both the tree and AI paths.
Export & sales hand-off. A finished Sidekick job exports as a branded printable report (jobReportHTML via a print window → Save as PDF), plain-text summary (clipboard / Web Share), or full JSON. A sales hand-off writes a shared fe:handoff:<id> record to same-origin localStorage and stamps the job; the Evaluator reads pending hand-offs on its Home and startFromHandoff() seeds a new survey (customer, address, the primary unit mapped to an Evaluator system type, and a handoffNote), then deletes the record. This is the device-local bridge between repair and sales until a server sync exists (a flagged beta gap).
Combustion / CO gate. Combustion-capable job types (furnace_no_heat, boiler_no_heat, water_heater_tank/_tankless) require a gas/electric declaration at close-out; gas appliances must record ambient CO, flue CO (air-free) and a spillage test before completeJob() will stamp the job. combustionDanger() (ambient ≥ 9 ppm, spillage fail, or flue ≥ 400 ppm air-free) blocks completion and sets a universal (no-override) hard stop for red-tag/escalate; safe readings are stamped (job.stamps.combustion) and printed on the report. Conservative, clearly-labeled action prompts — the tech defers to code/manufacturer limits.
Tree coverage & offline. TREES covers all seven job types (furnace, AC, boiler, ducted heat pump, mini-split, tank & tankless water heaters). The walker is deterministic and needs no backend, so checklists are the offline diagnostic path. A Net module (navigator.onLine + online/offline events) drives a header status pill and aiReady(); backend-dependent actions (diagnose, nameplate, identify, part_lookup, verify_part) short-circuit with a saved-inputs message when offline rather than timing out, and resume on reconnect. Persistent background replay/sync of queued calls is post-beta (with the server).
Feedback & customer sign-off. A closed job captures job.feedback {correct: yes/partial/no, actual, path, treeId, leaf, diagnosis} for beta learning (Admin shows a tally; detail exports in JSON). job.signoff {customerName, statement, signature(dataURL), at} is captured on a canvas signature pad (touch/mouse) from a plain-language customer summary; the statement adapts (authorization vs. red-tag/replacement acknowledgment). Both render on the printed report and export in JSON.
Equipment manuals (Sidekick). Backend manual_lookup (web search on brand+model) returns the manufacturer doc link plus AI-extracted sections: error/fault codes, sequence of operation, wiring notes, specs, combustion targets, safety notes, and a confidence rating. App.getManual(i) caches the result on component.manual (offline-readable, persisted on the job); the manual sheet is reachable per-component and from a persistent header icon. Fetch is online-only (fail-fast offline); cached sections and the source link persist. Extracted text is explicitly labeled as not replacing the manufacturer document for safety-critical setup.
Repair price, timer & completion photo (Sidekick close-out). job.billing {serviceChargeApplied, serviceCharge, repairTotal} captures an itemized service charge plus a single commingled parts+labor number (kept as one figure for tax); billTotal() = (service charge if applied) + repair cost. job.repairTimer {startedAt, completedAt, elapsedMs} starts when renderOutcome is first reached with a diagnosis and stops in completeJob (live clock via a 1s interval updating #repairclock). completeJob now also requires ≥1 completion photo (up to 10) on every job and a price entry; billing + repair time are stamped (stamps.billing, stamps.repairTimeMs) and shown on the summary/report/JSON for a repair-time dataset.
Repair-vs-replace (depreciation model). repairVsReplace(): annualDep = replaceCost ÷ life; breakEven = repairCost ÷ annualDep; expected remaining = max(0, life × conditionFactor − age). conditionFactor() sums the CONDITION_FACTORS checklist flags (clamped 0.5–1.4). Verdict: replace if remaining≤0 or breakEven≥remaining; repair if breakEven≤0.6×remaining; else borderline. Repair/replace costs are tech-editable (job.rr) and prefill from the entered price/estimate and the REPLACE_COST table. rrCustomerText() renders a plain-language (≈3rd-grade) explanation from the same figures (one source of truth), shown in the advisor’s customer panel and the sign-off summary.
Evaluator site conditions (v0.12.0). site now holds panels[] (each {role, amps, spaces, flags[], photos[], source}), duct{condition,type,returns,issues[],insulation,asbestos,staticPressure}, and priced access[]. panel_read (token-locked backend vision action) returns serviceAmps/openSpaces/redFlags/confidence; applyPanelRead maps them onto a panel (amps, spaces bucket none/some/several, flags, source). accessAdd sums ACCESS_ADDERS; computeTierTotals refreshes an adjustments.access line each recalc so it tracks the site step. Heat-pump-readiness and duct flags read the new model; migrateSite converts legacy surveys. The panel read is explicitly not a load calc and never instructs panel work; pre-1980 duct work raises an asbestos do-not-disturb flag.
New-system cost source. replacementCostRef(j) resolves the like-system cost in order: shared fe:pricebook (future inventory/cost tool) → Evaluator real quotes (evaluatorSellByType() reads fe:survey:*, pairs proposal.lineItems[i].sell with recommendation.systems[i].type, averages by mapped key) → normalized default. The advisor prefills from this and records the source (rr.replaceSource); eqLife()/eqDefaultReplace() normalize the job-type equipment label to a correct useful life and default cost (the old USEFUL_LIFE/REPLACE_COST tables keyed on different strings and mostly missed).
Image capture robustness. prepImage re-encodes via canvas but now falls back to the original FileReader bytes whenever the canvas yields nothing usable — image dimensions report 0 (HEIC/oversize), toDataURL base64 is empty, or decode errors — so any readable photo still reaches the vision API (previously these produced a silent blank). onCompPhoto/onIdentifyPhotos use per-file try/catch and skip empty results with a count; readCompAI reports when a label returned no brand/model/serial instead of implying a successful read.
Customer sign-off gate. completeJob requires job.signoff before finalizing; if absent it sets State.completeAfterSignoff and opens the sign-off screen (after all other gates). signoffStatement now confirms work completed + part shown/fix explained + price disclosed + authorization, and the billed total is shown and snapshotted (billedTotal). saveSignoff (signed) and saveSignoffNoSig (records noSignatureReason: not_on_site/tenant/phone/manager/declined) both call finishSignoff, which re-enters completeJob to finalize when completing. Report/summary/JSON show signed-by vs. no-signature + reason via signoffReasonLabel. Sign-off is never on the safety path — red-tag/escalate finalize independently.
Customer work order (printable). customerDocHTML(j) renders a branded, customer-facing work order/receipt printed via printCustomerDoc() (new tab + window.print). It pulls customerSummaryLines, rrCustomerText (when crossover), an itemized charges table from job.billing/billTotal, and signoffStatement + signature/no-signature reason — the same sources as the on-screen sign-off. It omits internal items (diagnostic trail, readings, stamps, EPA cert, feedback) which remain on the office report (jobReportHTML). customerSummaryLines now shows the work-done line once the installed part is selected (not only at status done) so the copy is complete when printed at sign-off.
Evaluator — proposal close. The proposal step captures an Accept/Decline decision; on Accept, a canvas signature pad (shared initSigPad/SIG_DIRTY pattern, init’d from render()) records the customer signature as a dataURL on proposal.signature, with accept() gating on a drawn signature + acknowledgment and setting the visit to ready_to_install. The signature prints on the proposal PDF. Share/export mirrors Sidekick: print, proposalText copy, JSON download of the full visit, and Web Share.
Evaluator — offline & backup. Shares Sidekick’s Net module/pill and fail-fast pattern: backend calls short-circuit with a saved/retry message when !Net.online (built-in estimate/sizing remain available offline). Settings → Data exports a full backup ({kind:"empower-backup", settings, surveys[]}) and imports either a full backup (optional settings restore via deepMerge) or a single-visit export, running migrateCustomer on import.
Technical reference for the Field Engineer tool — Sales/Survey (Stage 1) and Technician/Repair (Stage 2, first pass). Reflects the build as currently deployed; update alongside future changes.