Tutorial // Agents2026-07-2310 min read

We Open-Sourced an AI Agent That Catches the Markup/Margin Error Costing Contractors Money

agent-for-field-service is a free, self-hosted AI copilot for HVAC, plumbing, electrical, and roofing contractors that prices jobs to a real margin instead of a markup that only looks like one.

Varun Raj Manoharan
Varun Raj ManoharanFounder & Principal Engineer
Open SourceAI AgentsevePricingField ServiceTypeScript

Key takeaways

  • Markup and margin are different denominators, not different words for the same thing: a 30% markup runs the numbers to a 23.08% margin, a 6.92-point gap the code verifies rather than approximates.
  • Pricing off a raw hourly wage instead of the burdened rate (payroll tax, insurance, vehicle, benefits) understates true job cost, the repo's jobCost() applies burden before hours are multiplied in, not as a vague overhead guess afterward.
  • The agent's own weather tool shipped a real bug: Open-Meteo returns null for forecast values past its horizon, Number(null) coerces to 0, and a day with no data rendered as a perfectly calm, dry, workable day until the fix checked the raw type before coercion.
  • It deploys to your own Vercel project with exactly one API key, because the weather tool calls Open-Meteo's free, keyless forecast endpoint instead of a metered third-party data API.

We just published agent-for-field-service on GitHub, MIT-licensed. It's an AI agent for HVAC, plumbing, electrical, and roofing contractors: you describe a job in plain language, it gives you back an itemised quote with labour, materials, and the actual margin that price produces. It runs in your own Vercel project. No account, no SaaS middleman, nothing stored.

The reason it exists is a piece of arithmetic almost every contractor gets wrong, and gets wrong in the same direction every time.

The error the whole agent exists to fix

Say your true cost on a job is $100 and you "mark it up 30%." You price it at $130. You look at that $30 of profit and think: I made 30%.

You didn't. You made 23.08%.

The mistake is which number you're dividing by. Markup is profit over cost: how much you added on top of what you spent. Margin is profit over price: how much of what the customer actually paid you get to keep. They share a numerator and disagree on the denominator, and the disagreement is not small:

INI
markup = (price − cost) / cost   =  30 / 100  = 30.00%
margin = (price − cost) / price  =  30 / 130  = 23.08%

That's not an approximation. It's agent/lib/pricing.ts's markupToMargin(), run for real:

LESS
$ node -e 'import("./agent/lib/pricing.ts").then(m =>
  console.log(m.markupToMargin(30)))'
{ ok: true, value: 23.08 }

30 minus 23.08 is 6.92 points, on every single job priced this way. Round it and it's the "7-point profit leak" the README leads with. The repo is honest that 6.92 rounds to 7, not that 7 is the real number. The gap isn't fixed at 6.92 either; it moves with the target. marginToMarkup(50) (a contractor trying to keep half of what they charge) returns exactly 100, because at that margin you have to double your cost just to keep half the selling price as profit. The direction of the error never changes: a stated markup always overstates the margin it actually produces. Only the size of the gap moves.

The formulas are algebraic identities, not heuristics: margin = markup / (100 + markup), markup = margin / (100 − margin). agent/lib/pricing.ts refuses a margin at or above 100% and a markup at or below -100% rather than dividing by zero or handing back Infinity, since both would imply zero or negative cost, which isn't a pricing problem the tool should quietly paper over.

What it looks like on a real job

The README's worked example is diagnosing and replacing a failed run capacitor and contactor on a residential AC condenser: 2.5 hours labour at $28/h raw wage, 32% burden, $95 in parts, priced to a 35% margin. I ran buildQuote() directly rather than retype the README's numbers:

LESS
$ node -e 'import("./agent/lib/pricing.ts").then(m =>
  console.log(JSON.stringify(m.buildQuote({
    lines: [{
      description: "Diagnose and replace failed run capacitor and contactor on residential AC condenser",
      labourHours: 2.5, hourlyWageDollars: 28, burdenPct: 32, materialCostDollars: 95
    }],
    targetMarginPct: 35
  }), null, 2)))'
JSON
{
  "ok": true,
  "lines": [
    {
      "description": "Diagnose and replace failed run capacitor and contactor on residential AC condenser",
      "burdenedHourlyRateDollars": 36.96,
      "labourCostDollars": 92.4,
      "materialCostDollars": 95,
      "totalCostDollars": 187.4,
      "totalCostFormatted": "$187.40"
    }
  ],
  "totalCostDollars": 187.4,
  "overheadDollars": 0,
  "fullyLoadedCostDollars": 187.4,
  "priceDollars": 288.31,
  "priceFormatted": "$288.31",
  "targetMarginPct": 35,
  "resultingMarkupPct": 53.85
}

Two things worth sitting with. First, the $28/h wage becomes a $36.96/h burdened rate before it ever gets multiplied by hours. More on that below. Second, resultingMarkupPct is 53.85, not 35: a 35% margin on this job needs a 53.85% markup to actually land. If you'd priced the job at a 35% markup instead (the number most contractors would reach for, believing it's the margin), you'd have charged less and kept less:

INI
$ node -e 'import("./agent/lib/money.ts").then(money => {
  const cost = money.toCents(187.40);
  const price = money.toCents(money.fromCents(cost) * 1.35);
  console.log("price:", money.usd(price));
  console.log("margin:", money.pct(price - cost, price));
})'
price: $252.99
margin: 25.93

$252.99 instead of $288.31: you'd have quietly given away $35.32 on this one job, while believing you were pricing it at the margin you wanted. That gap, made visible on every quote instead of left implicit, is the entire reason this agent exists.

True cost means burden, not the number on the paycheck

The $28 → $36.96 jump above isn't a rounding artifact. It's burden. jobCost() in agent/lib/pricing.ts treats payroll tax, workers' comp and other insurance, vehicle costs, and benefits as a percentage on top of raw wage, applied before hours get multiplied in, not folded into a vague "overhead" line at the end:

TypeScript
export function jobCost(input: JobCostInput): JobCostResult {
  const wageCents = toCents(input.hourlyWageDollars);
  const burdenedHourlyCents = toCents(
    fromCents(wageCents) * (1 + input.burdenPct / 100),
  );
  const labourCents = toCents(fromCents(burdenedHourlyCents) * input.labourHours);
  const materialCents = toCents(input.materialCostDollars ?? 0);
  const totalCents = labourCents + materialCents;
  // ...
}

Run it standalone on a slightly bigger job: 3 hours at $28/h, 32% burden, $140 in materials:

LESS
$ node -e 'import("./agent/lib/pricing.ts").then(m =>
  console.log(JSON.stringify(m.jobCost({
    labourHours: 3, hourlyWageDollars: 28, burdenPct: 32, materialCostDollars: 140
  }), null, 2)))'
JSON
{
  "burdenedHourlyRateDollars": 36.96,
  "labourCostDollars": 110.88,
  "materialCostDollars": 140,
  "totalCostDollars": 250.88,
  "totalCostFormatted": "$250.88"
}

Price that job off the $28/h you actually write on the paycheck and you've understated your labour cost by nearly $22 before you've even added a dollar of profit. The point isn't that burden is 32% specifically. That number is whatever your payroll tax rate, insurance premium, and vehicle cost actually are, and the agent asks for it rather than guessing. The point is that raw wage is never the true cost of an hour of labour, and pricing as if it were is a second, independent way to quietly lose money on a job that looks profitable on paper.

How it's built: pure logic, thin tools

The codebase is split deliberately into agent/lib/ and agent/tools/, and the reason is testability without a model in the loop. Every pricing rule, every weather threshold, lives as a plain typed function in agent/lib/ (markupToMargin(), jobCost(), buildQuote(), weatherWindow()), with no defineTool, no Zod, no awareness that a model exists. tests/ imports directly from lib/, so npm test runs the entire suite with zero environment variables and zero network calls.

agent/tools/ is where each of those functions gets exposed to the model, and each wrapper is kept deliberately thin: parse input with Zod, call the lib function, return the result. Here's the whole of build_quote.ts:

TypeScript
import { defineTool } from "eve/tools";
import { z } from "zod";
import { buildQuote } from "../lib/pricing.ts";

export default defineTool({
  description:
    "Build an itemised quote priced to hit a target margin (not markup) — labour lines include burden, an optional overhead percent is layered on top, and the price is solved so the target margin is actually realised. Returns the resulting markup alongside the margin so the two are never confused. Always use this instead of adding a markup percentage by hand.",
  inputSchema: z.object({
    lines: z.array(z.object({
      description: z.string(),
      labourHours: z.number(),
      hourlyWageDollars: z.number(),
      burdenPct: z.number().describe("Percent burden on top of raw wage"),
      materialCostDollars: z.number().optional(),
    })).min(1),
    targetMarginPct: z.number()
      .describe("The margin (percent of price that is profit) to price the job at, e.g. 40"),
    overheadPct: z.number().optional()
      .describe("Overhead percent applied on top of total line cost before pricing to margin"),
  }),
  async execute(input) {
    return buildQuote(input);
  },
});

Seven tools follow that same shape in total: build_quote, margin_vs_markup, job_cost, weather_window, calculate (the arithmetic escape hatch: agent/instructions.md forbids the model from doing math in its own head, so anything not covered by a more specific tool goes through this), ingest_document, and export_document. On top of the tools, four markdown playbooks in agent/skills/ (pricing for profit, change orders, callback triage, seasonal capacity) load into context only when the model's routing decides a request needs them, so the base system prompt doesn't grow with every playbook the repo accumulates.

The other thing worth calling out: every dollar figure that reaches the screen runs through integer cents via agent/lib/money.ts, never bare floating-point dollars. toCents() and fromCents() convert at the boundary; roundAwayFromZero() rounds ties away from zero instead of Math.round's toward-positive-infinity bias, so a negative figure is the exact mirror of its positive twin instead of a cent short. This matters because floating-point dollar arithmetic is a silent failure mode: 0.1 + 0.2 isn't 0.3 in IEEE 754, and on a quote with several line items and a percentage layered on top, those sub-cent errors compound into numbers that are wrong by a cent or two in a way that's very hard to notice and mildly embarrassing to explain to a customer who adds up your own line items and gets a different total. Cents are integers; integers don't drift.

The weather tool, and the bug worth teaching

weather_window checks whether the next several days are workable for a job at a location (dry and calm for roofing, unfrozen ground for excavation, and so on) by calling Open-Meteo's forecast API, which needs no API key or account.

It shipped with a real defect, and it's the most useful engineering story in the repo. Open-Meteo returns null for precipitation_probability_max on days past its probability forecast horizon. The temperature and time arrays are still fully populated, just not that one field. The original parsing code did this:

TypeScript
tempMaxC: Number(tMax[i]),
tempMinC: Number(tMin[i]),
precipitationProbabilityPct: Number(precip[i]),
windSpeedMaxKph: Number(wind[i]),

Number(null) is 0 in JavaScript, a perfectly plausible "0% chance of rain" reading. Reconstructing that old logic against a stub response with a null past the horizon shows exactly what it produced:

INI
$ node -e '
const daily = { precipitation_probability_max: [10, null], wind_speed_10m_max: [12, 8] };
console.log(Number(daily.precipitation_probability_max[1]));'
0

A day with no data from Open-Meteo came out the other side as a clean, dry, calm, workable: true day, invented from nothing, because the coercion happened before anything checked whether the value was real.

The fix, in the commit that shipped it (Fix weather_window fabricating workable days from bad Open-Meteo data), checks the raw value's type before any coercion happens, and rejects the whole day instead of quietly substituting a number:

TypeScript
// Reject on the raw value's type instead, before any coercion happens.
const toFiniteNumber = (value: unknown): number | null =>
  typeof value === "number" && Number.isFinite(value) ? value : null;

const rawDays = time.map((date: unknown, i: number) => ({
  date: String(date),
  tempMaxC: toFiniteNumber(tMax[i]),
  tempMinC: toFiniteNumber(tMin[i]),
  precipitationProbabilityPct: toFiniteNumber(precip[i]),
  windSpeedMaxKph: toFiniteNumber(wind[i]),
}));

const incomplete = rawDays.some((day) => day.precipitationProbabilityPct === null /* ... */);
if (incomplete) {
  return { ok: false, reason: "unavailable", detail: "Open-Meteo response contained incomplete or missing forecast values for one or more days." };
}

Run the same stub response against the fixed code and the day never gets a verdict fabricated for it:

JSON
{
  "ok": false,
  "reason": "unavailable",
  "detail": "Open-Meteo response contained incomplete or missing forecast values for one or more days."
}

The same commit also caught a second version of the same bug shape: a value array shorter than the time array, where arr[i] is undefined and Number(undefined) is NaN, which happens to fail every numeric threshold comparison and also reads as workable. It also added a request timeout, because without one a hung connection blocked until the hosting platform killed the function instead of returning a typed failure. agent/instructions.md backs this at the prompt level too: the model is hard-forbidden from describing a day as workable when weather_window returns { ok: false }, and told to say plainly that the forecast couldn't be retrieved rather than filling the gap with general seasonal knowledge.

The lesson generalizes past this one API: any time you coerce an external value with Number(), String(), or a default operator before checking whether it was actually present, you've built a code path where "missing" and "a real zero" produce identical output. Check the type first.

Deploying it

The whole distribution story is a Vercel deploy button. Click it, and Vercel prompts for exactly three values: AI_GATEWAY_API_KEY (from Vercel's AI Gateway, pays for the model calls: agent/agent.ts pins anthropic/claude-sonnet-5 directly), and AGENT_BASIC_AUTH_USER / AGENT_BASIC_AUTH_PASSWORD, a username and password you pick yourself. There's no fourth key to hunt down, because weather_window calls Open-Meteo, which is free and keyless. Unlike an agent built against a metered weather API, there's nothing here to configure, rate-limit, or run out of quota on that path. That's a genuine, structural advantage over the alternative, not a marketing line: one fewer credential to provision, rotate, or leak.

Leave the basic-auth pair unset and the deployment still builds and goes live, but agent/channels/eve.ts's placeholder auth rejects every request in production on purpose, rather than silently serving an unauthenticated agent to whoever finds the URL. Basic auth over HTTPS is adequate for one operator or a small team sharing a credential; it's not a substitute for a real identity provider with per-user audit trails if more than one person needs distinguishable access.

Running locally is the usual shape:

Shell
git clone https://github.com/FoundrySoftHQ/agent-for-field-service
cd agent-for-field-service
cp .env.example .env   # fill in AI_GATEWAY_API_KEY
npm install
npm run dev

Node 24 or later is required: the repo ships plain .ts files with no separate compile step, relying on Node's native TypeScript support (npm test runs node --test "tests/**/*.test.ts" directly), so an older runtime fails to even start rather than running in some degraded mode.

What it doesn't do

The README is upfront about this and it's worth repeating rather than glossing over: there is no material-price feed. materialCostDollars in every tool is a number the contractor supplies. If you ask the agent what a capacitor or a bundle of shingles costs this week, it will tell you it doesn't know rather than inventing a plausible-sounding figure. It's a calculator and a set of playbooks, not a catalogue. The same goes for burden and overhead percentages: it doesn't know your actual payroll tax rate or insurance premium, so a quote built on a guessed burden figure is only as good as the guess. It's also not a licensed engineer, electrician, or building official: load calculations and code-compliance calls get routed to "ask your local authority having jurisdiction," not answered as if the agent held the license. And weather comes from one free forecast API, not hyper-local truth; workable: true means "nothing in the forecast rules this out," not a guarantee against a valley that floods or a coastal wind corridor Open-Meteo's model doesn't resolve.

None of that is a knock against the tool. It's the boundary of what a pricing calculator and a scheduling check are supposed to know, stated plainly instead of left for you to discover the expensive way.

Try it

The repo is FoundrySoftHQ/agent-for-field-service on GitHub, MIT-licensed, npm test runs clean with zero environment variables, and every claim in the README is supposed to trace back to a formula or a test rather than a hand-typed number. We've already had to correct one that wasn't. Fork it, point it at your own numbers, add a tool or a skill if your trade works differently. If you build something on top of it, we'd like to hear about it.

Available for new projects

Let's build something great.

Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.

See our work