---
title: "We Open-Sourced an AI Agent That Catches the Markup/Margin Error Costing Contractors Money"
description: "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."
image: "https://foundrysoft.co/api/og?type=article&title=We+Open-Sourced+an+AI+Agent+That+Catches+the+Markup%2FMargin+Error+Costing+Contractors+Money&cat=Tutorial+%2F%2F+Agents&rt=10+min+read&au=Varun+Raj+Manoharan&dt=2026-07-23"
url: "https://foundrysoft.co/blog/open-source-ai-agent-contractor-quoting-margin"
---

Tutorial // Agents 2026-07-23 10 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](https://foundrysoft.co/images/about/founder.webp)

Varun Raj Manoharan Founder & Principal Engineer

Open Source AI Agents eve Pricing Field Service TypeScript

## 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`](https://github.com/FoundrySoftHQ/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

Copy

```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

Copy

```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

Copy

```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

Copy

```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

Copy

```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

Copy

```ts
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

Copy

```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

Copy

```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

Copy

```ts
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](https://open-meteo.com/)'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

Copy

```ts
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

Copy

```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

Copy

```ts
// 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

Copy

```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

Copy

```bash
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](https://github.com/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.

#### Related reading

[Why Trusting AI Generated Code Is the Wrong Goal

Trusting AI generated code was never the right goal, and the 4 percent of developers who say they fully trust it prove nothing is broken: the fix is an AI code review process that makes verification cheap instead of asking how much to trust the output.

AI Code Review Developer Trust Code Quality

](https://foundrysoft.co/blog/developers-dont-trust-ai-generated-code)[Five AI Agents, One Bug: When Missing Data Looks Like a Clean Result

We built and shipped five open-source vertical AI agents. Every single one had the same class of defect: absent or unreadable input rendered as a confident, clean answer. Here is what that bug looks like, why tests miss it, and what actually catches it.

AI Agents Testing Open Source

](https://foundrysoft.co/blog/five-ai-agents-one-bug-missing-data-clean-result)[Best Open Weight LLMs for Agents in 2026

A practical look at the best open weight LLMs for agents in 2026, organized by which constraint, cost, latency, or data residency, should actually decide the pick.

Open Weight LLMs AI Agents LLM Comparison

](https://foundrysoft.co/blog/best-open-weight-llms-agents-2026)

#### Next Article

[

How to Automate a Legacy System That Has No API (Without Waiting for IT)

](https://foundrysoft.co/blog/automate-legacy-systems-without-api-computer-use)

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.

Start a Project [See our work](https://foundrysoft.co/work)

```json
{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "FoundrySoft",
  "url": "https://foundrysoft.co",
  "logo": "https://foundrysoft.co/logo.svg",
  "description": "FoundrySoft builds production-grade software and AI systems for US companies, from an India-based team of senior engineers.",
  "sameAs": [
    "https://github.com/foundrysofthq",
    "https://www.linkedin.com/company/foundrysoft",
    "https://www.instagram.com/foundrysoft/"
  ]
}
```

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "FoundrySoft",
  "url": "https://foundrysoft.co"
}
```

```json
{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "We Open-Sourced an AI Agent That Catches the Markup/Margin Error Costing Contractors Money",
  "description": "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.",
  "url": "https://foundrysoft.co/blog/open-source-ai-agent-contractor-quoting-margin",
  "mainEntityOfPage": "https://foundrysoft.co/blog/open-source-ai-agent-contractor-quoting-margin",
  "image": [
    "https://foundrysoft.co/images/blog/open-source-ai-agent-contractor-quoting-margin.webp"
  ],
  "datePublished": "2026-07-23",
  "dateModified": "2026-07-23",
  "keywords": "Open Source, AI Agents, eve, Pricing, Field Service, TypeScript",
  "author": {
    "@type": "Person",
    "name": "Varun Raj Manoharan"
  },
  "publisher": {
    "@type": "Organization",
    "name": "FoundrySoft",
    "logo": {
      "@type": "ImageObject",
      "url": "https://foundrysoft.co/logo.svg"
    }
  }
}
```

```json
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "https://foundrysoft.co/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Blog",
      "item": "https://foundrysoft.co/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "We Open-Sourced an AI Agent That Catches the Markup/Margin Error Costing Contractors Money",
      "item": "https://foundrysoft.co/blog/open-source-ai-agent-contractor-quoting-margin"
    }
  ]
}
```
