---
title: "We Open-Sourced the AI Agent We Use to Scope Work and Watch Retainers"
description: "agent-for-agencies is an open-source AI copilot that prices scope, drafts SOWs, and catches an underwater retainer before the quarter's numbers do. Here's the arithmetic underneath it, verified against the code."
image: "https://foundrysoft.co/api/og?type=article&title=We+Open-Sourced+the+AI+Agent+We+Use+to+Scope+Work+and+Watch+Retainers&cat=Tutorial+%2F%2F+Agents&rt=12+min+read&au=Varun+Raj+Manoharan&dt=2026-08-03"
url: "https://foundrysoft.co/blog/open-source-ai-agent-agency-sow-retainer"
---

Tutorial // Agents 2026-08-03 12 min read

# We Open-Sourced the AI Agent We Use to Scope Work and Watch Retainers

agent-for-agencies is an open-source AI copilot that prices scope, drafts SOWs, and catches an underwater retainer before the quarter's numbers do. Here's the arithmetic underneath it, verified against the code.

![Varun Raj Manoharan](https://foundrysoft.co/images/about/founder.webp)

Varun Raj Manoharan Founder & Principal Engineer

AI Agents Open Source Agency Ops eve Pricing

## Key takeaways

-   blendedRate() is the rate an estimate was planned at; effectiveHourlyRate() is the rate a fixed fee actually paid once real hours landed, the gap between the two is what quietly kills fixed-fee and retainer work, and retainer_profitability now reports both in one call.
-   build_sow refuses outright, returning ok: false instead of a document, when exclusions or assumptions are blank, whitespace-only, or made of zero-width characters, because an SOW without stated exclusions is the most common cause of scope disputes.
-   Every dollar figure in the repo is computed in integer cents through agent/lib/money.ts, and every divisor (zero hours, zero capacity, zero fee) resolves to a stated zero or a typed error, never Infinity or NaN.
-   A real bug shipped here: build_sow's estimate input was typed z.any(), and a malformed estimate object rendered a client-facing SOW reading 'Total: undefined across undefined hours.' The fix was a Zod schema at the tool boundary plus a matching guard in the library function itself.

Scoping is where agency money is won or lost, and it happens badly in the same two places every time. First: the SOW goes out without an explicit exclusions list, because writing "anything not listed above is out of scope" feels like it covers you and doesn't. Second: a retainer gets quoted at a healthy-looking blended rate, and six months later nobody can say whether it's still profitable, because the number everyone's watching (the blended rate at signing) isn't the number that determines whether the retainer makes money. That's the effective rate, and it moves every month scope creeps while the invoice stays flat.

We built [`agent-for-agencies`](https://github.com/FoundrySoftHQ/agent-for-agencies) to do the arithmetic for both of those, and open-sourced it (MIT) rather than keep it as an internal tool. It's a small AI copilot (eight tools, four playbooks, no SaaS in the middle) that turns discovery notes into a priced, exclusion-bearing SOW, and separately tells you whether a retainer is actually making money once real hours land against it. It runs in your own Vercel project. There's no account, no third-party API key beyond the model provider you already pick, and no telemetry: the repo is 100% of what ships.

This post is not a feature tour. Every number and every output block below came from actually running the code in this repo (`agent/lib/estimating.ts` and `agent/lib/sow.ts`), not from a mocked-up example. If a claim here doesn't trace to code, it shouldn't be in the post; that's the rule the repo holds itself to and I'm holding this post to it too.

## The blended rate is not the rate you get paid

Two functions in `agent/lib/estimating.ts` compute what sounds like the same thing and isn't.

`blendedRate()` divides total planned cost by total planned hours. It's the number that goes in the proposal: "this engagement blends out to $150/hour across the team." It is, by construction, correct at the moment you write it down, because it's built from the hours and rates you planned.

`effectiveHourlyRate()` divides a fixed fee by the hours actually delivered against it. It's the number that's true in arrears. If a project or a retainer runs over the planned hours without a change order, the fee doesn't move but the hours do. The effective rate quietly drops below the blended rate that was quoted.

Nothing forces those two numbers to match, and nothing tells you when they've diverged unless you go compute both and subtract. So `retainer_profitability` does the subtraction for you. It optionally accepts `plannedBlendedRateDollarsPerHour` (typically `estimate_scope`'s own `totals.blendedRateDollarsPerHour`) and returns `rateGapDollarsPerHour` and `rateGapFlag` in the same call, instead of making you run `blendedRate` and `effectiveHourlyRate` separately and eyeball the difference.

Here's a retainer that looks fine on the number everyone's watching, and isn't on the number that matters. Fee is $12,000/month, planned at a $150 blended rate. This period, the team delivered 90 developer hours and 30 designer hours, at cost rates of $80 and $60/hour:

JavaScript

Copy

```js
retainerProfitability({
  retainerFeeDollars: 12000,
  hoursDelivered: { developer: 90, designer: 30 },
  costRates: { developer: 80, designer: 60 },
  plannedBlendedRateDollarsPerHour: 150,
});
```

JSON

Copy

```json
{
  "ok": true,
  "totalHours": 120,
  "totalCostDollars": 9000,
  "totalCostFormatted": "$9,000.00",
  "marginDollars": 3000,
  "marginFormatted": "$3,000.00",
  "marginPct": 25,
  "effectiveRateDollarsPerHour": 100,
  "effectiveRateFormatted": "$100.00",
  "isUnderwater": false,
  "note": "Healthy margin of $3,000.00 (25% of the fee). The effective rate has fallen $50.00/hour below the planned blended rate of $150.00/hour.",
  "plannedBlendedRateDollarsPerHour": 150,
  "rateGapDollarsPerHour": -50,
  "rateGapFlag": true
}
```

Margin is positive, 25% of the fee. Nothing here trips the "this retainer is losing money" alarm. But the effective rate is $100/hour against a planned $150, a $50/hour gap, and `rateGapFlag` is `true`. That's the retainer equivalent of a slow leak: cost rates are below billing rates, so there's still cash left over this month, but scope has crept enough that the team is doing $150/hour work for $100/hour, and every month it keeps creeping the margin dollar figure erodes even though nothing in the invoice changes.

Push the same retainer further (140 developer hours and 40 designer hours instead of 90 and 30) and the same call reports it honestly on the other side of zero:

JSON

Copy

```json
{
  "ok": true,
  "totalHours": 180,
  "totalCostDollars": 13600,
  "marginDollars": -1600,
  "marginFormatted": "-$1,600.00",
  "marginPct": -13.33,
  "effectiveRateDollarsPerHour": 66.67,
  "isUnderwater": true,
  "note": "This retainer is underwater: real cost exceeded the fee by $1,600.00. The effective rate has fallen $83.33/hour below the planned blended rate of $150.00/hour.",
  "rateGapDollarsPerHour": -83.33,
  "rateGapFlag": true
}
```

`marginDollars` is `-1600`, not `0`, not "tight," not "thin." `agent/instructions.md` tells the model to report an underwater retainer plainly, in the first sentence, with the real dollar loss. The library gives it nothing else to report, because `marginDollars` is never clamped. That's a deliberate choice, not an oversight: the entire value of computing this number is that it can go negative and say so. A tool that quietly floors a loss at zero is a tool that hides the one number an agency owner most needs to see before the quarter closes, not the middle of it.

## The SOW builder refuses

The other half of the repo is `build_sow`, backed by `agent/lib/sow.ts`. It takes `clientName`, `projectName`, `scope`, `phases` (each with a `name` and `description`), `assumptions`, `exclusions`, and `acceptanceCriteria`, and renders a markdown SOW. Two of those fields are non-negotiable: `assumptions` and `exclusions`. If either is empty, `buildSow` doesn't produce a partial document with a gap in it: it refuses.

JavaScript

Copy

```js
buildSow({
  clientName: "Acme Co",
  projectName: "Website rebuild",
  scope: "Rebuild the marketing site.",
  phases: [{ name: "Build", description: "Build the site." }],
  assumptions: ["Client supplies copy."],
  exclusions: [],
  acceptanceCriteria: "Client sign-off on staging.",
});
```

JSON

Copy

```json
{
  "ok": false,
  "error": "Refusing to produce an SOW without an explicit exclusions list. An SOW without stated exclusions is the single most common cause of scope disputes — list what is out of scope before this can be generated."
}
```

The refusal isn't a naive `length === 0` check either. `exclusions: [" ", "​"]` (a whitespace string and a lone zero-width space) produces the identical refusal:

JSON

Copy

```json
{
  "ok": false,
  "error": "Refusing to produce an SOW without an explicit exclusions list. An SOW without stated exclusions is the single most common cause of scope disputes — list what is out of scope before this can be generated."
}
```

The reason that distinction exists at all: `\s` in a regular expression does not match Unicode "format" characters like a zero-width space or a BOM. A naive `.trim().length > 0` check treats a string made entirely of them as non-blank, so an entry like that would satisfy a lazy guard and still render as nothing in the document: an exclusions section that looks populated and is, functionally, empty. `agent/lib/sow.ts`'s `nonBlank()` strips `\p{Cf}` (the Unicode "format" category) before testing for content, specifically to close that gap.

Why refuse instead of degrading gracefully (filling in a placeholder exclusion, say)? Because the entire value proposition of an SOW's exclusions list is that someone had to actually think about what's out of scope and write it down. A tool that always produces a document trains the person using it to stop checking whether the exclusions are real. A tool that says no, here, specifically, forces the one conversation an SOW exists to have before it gets sent.

It's worth being honest about the limit of this guard too: it checks that exclusions and assumptions are _present_, not that they're _good_. A one-line placeholder exclusion satisfies it and still leaves real scope-creep risk on the table. The `sow-clauses` skill exists to help write exclusions that actually hold up: "Additional pages beyond the 6 listed in Phase 2 are out of scope," not "anything not explicitly included is excluded." But the tool itself can only enforce presence, not quality. That's a real gap, and pretending otherwise would defeat the point of this post.

## How it's built

Every capability splits into two files: a pure function in `agent/lib/` and a thin `defineTool` wrapper in `agent/tools/` that gives it a Zod input schema and exposes it to the model. `estimateScope()`, `blendedRate()`, `effectiveHourlyRate()`, `retainerProfitability()`, `utilisation()`, and `priceChangeRequest()` all live in `agent/lib/estimating.ts` with no `defineTool`, no Zod, no awareness that a model exists. `buildSow()` lives in `agent/lib/sow.ts` the same way.

The reason for the split is testability without a model in the loop. Every test in `tests/` imports from `agent/lib/`, not `agent/tools/`, and runs with zero environment variables and zero network calls: `npm test` covers rounding edge cases, divide-by-zero guards, and refusal paths as plain function calls with plain assertions. A `defineTool` wrapper stays thin enough that it doesn't need its own tests: parse input with Zod, call the lib function, return the result.

Every dollar figure that reaches a user's screen goes through `agent/lib/money.ts`: dollars convert to integer cents with `toCents()`, arithmetic happens in cents, and the result converts back with `fromCents()` or formats with `usd()` only at the return boundary. `roundAwayFromZero()` rounds ties away from zero rather than `Math.round`'s toward-positive-infinity bias, so a negative margin and its positive mirror image round to the same magnitude in cents. Without it, `-$1,600.005` and `$1,600.005` could round to different absolute values, which is exactly the kind of asymmetry you don't want in a number someone's invoicing against. `pct()` computes percentages the same integer-first way and returns a stated `0` when the denominator is `0`, rather than `NaN`.

`agent/lib/estimating.ts` divides in five places, and each is guarded explicitly:

-   `blendedRate` on zero total hours returns `{ ok: false, error }`. There's no meaningful blended rate for zero hours of work.
-   `effectiveHourlyRate` on zero or negative hours delivered returns `{ ok: false, error }`.
-   `retainerProfitability` folds a zero-hours-delivered retainer into `effectiveRateDollarsPerHour: null` rather than refusing outright, because a retainer that delivered zero hours is still a real, reportable state. Margin and cost are still meaningful. Its `marginPct` computation still guards a `$0` fee to a stated `0` via `pct()`.
-   `utilisation` on zero or negative capacity returns `{ ok: false, error }`. There's no utilisation percentage against zero available hours.
-   `estimateScope`'s blended-rate total reuses `blendedRate` internally, so it gets the same guard for free instead of duplicating it.

None of these functions throw. Every one returns `{ ok: true, ... } | { ok: false, error }`, because the model calls these directly and a thrown exception is a dead end it can't recover from. A typed refusal is something it can read and relay.

## The bug worth telling on

`build_sow`'s tool wrapper originally typed its `estimate` input as `z.any()`. The intent was reasonable: `estimate` is meant to be `estimate_scope`'s own output, passed straight through, and `z.any()` felt like the path of least resistance for "just accept whatever that tool returned."

It broke exactly the way you'd expect an untyped boundary to break. A model re-serialising a large `estimate_scope` result before calling `build_sow` (normal behavior, not an edge case) could drop a field along the way. `{ ok: true, totals: {}, phases: [] }` passed `z.any()` cleanly and rendered a "Priced estimate" section reading:

JavaScript

Copy

```javascript
Total: undefined across undefined hours.
```

in a document meant to go to a client. A worse variant (`{ ok: true }` with no `totals` key at all) didn't render garbage, it threw, which broke the never-throws contract every other function in the repo honours.

The fix went in at both layers, deliberately, rather than trusting one:

`agent/tools/build_sow.ts` now declares an explicit Zod union covering `ok`, `totals.totalHours`, `totals.totalCostFormatted`, and each phase's `name`/`totalHours`/`totalCostFormatted`: `.passthrough()` on each object so it doesn't have to duplicate every field `estimate_scope` returns, just the ones `build_sow` actually reads. That's the schema stopping a malformed estimate from entering the function at all.

`agent/lib/sow.ts` added `estimateHasRequiredFields()` as a second, independent check inside `buildSow()` itself:

JavaScript

Copy

```js
buildSow({
  clientName: "Acme Co",
  projectName: "Website rebuild",
  scope: "Rebuild the marketing site.",
  phases: [{ name: "Build", description: "Build the site." }],
  assumptions: ["Client supplies copy."],
  exclusions: ["Ongoing maintenance after launch."],
  acceptanceCriteria: "Client sign-off on staging.",
  estimate: { ok: true, totals: {}, phases: [] },
});
```

JSON

Copy

```json
{
  "ok": false,
  "error": "The supplied estimate is missing fields build_sow needs (totals.totalCostFormatted, totals.totalHours, and each phase's name/totalCostFormatted/totalHours). Refusing rather than embedding a priced section with a gap in it."
}
```

That second check matters even though the Zod schema already exists, because `buildSow()` is a plain exported function, not a tool: a hand-rolled caller, a script, a future refactor that calls it directly, isn't bound by the tool's schema at all. The schema stops it entering through the model's path; the library function refuses on its own even with no schema in front of it. Belt and braces, for an artefact that's a priced document with a client's name on it.

It's a small bug in isolation (an `undefined` in a markdown string) but it's the class of bug that matters here specifically because of what the artefact is. A malformed number in a debug log is a shrug. A malformed number in a document you're about to send a client to sign is a different kind of problem, and "the model dropped a field while re-serialising a large object" is the ordinary case for a tool boundary like this, not the exotic one worth deprioritizing.

## Deploying it, and what it won't do

It's a click-deploy to Vercel: paste an AI Gateway key, set `AGENT_BASIC_AUTH_USER` / `AGENT_BASIC_AUTH_PASSWORD` so a deployed instance doesn't serve unauthenticated traffic (eve's local-dev auth bypass means you can skip these running `npm run dev` locally), and that's the whole setup: three values, one prompt. There's no hosted service and no account; it runs in your own Vercel project, on whatever model your AI Gateway routes to.

The honest limits matter as much as the tools do. It has no time-tracking, CRM, or accounting integration: no Harvest, no HubSpot, no QuickBooks. Every hour, rate, and fee it reasons about is what you type into the conversation; if your actual delivered hours live in a time-tracking tool, getting them into this agent is on you. It has no market-rate data: ask it what to bill and it'll tell you it doesn't know, not guess. And it's not a lawyer: SOW clauses from the `sow-clauses` skill are a starting draft for review by counsel, and every skill and the SOW's own footer say so.

That's a real tradeoff, not a hedge. The number this agent gives you is only as good as the hours and rates you feed it, and it will not pretend otherwise by inventing a market rate or backfilling a number you didn't provide. What it will do reliably is the arithmetic (the blended-vs-effective gap, the guarded divisors, the SOW that refuses to ship without exclusions), the part that doesn't depend on data it doesn't have, computed the same correct way every time because it's tested the same way every time: `npm test` runs with zero environment variables and zero network calls, against the same `agent/lib/` functions this post ran directly.

The repo is at [github.com/FoundrySoftHQ/agent-for-agencies](https://github.com/FoundrySoftHQ/agent-for-agencies), MIT licensed. Fork it, swap in your own rates and skills, or just read `agent/lib/estimating.ts` and `agent/lib/sow.ts` if you want to see the guards described here in full. If you add a pricing rule or a claim, the repo's own contributing note asks for a test behind it: same rule this post followed.

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

[

When Not to Build a Multi-Agent System

](https://foundrysoft.co/blog/when-not-to-build-multi-agent-system)

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 the AI Agent We Use to Scope Work and Watch Retainers",
  "description": "agent-for-agencies is an open-source AI copilot that prices scope, drafts SOWs, and catches an underwater retainer before the quarter's numbers do. Here's the arithmetic underneath it, verified against the code.",
  "url": "https://foundrysoft.co/blog/open-source-ai-agent-agency-sow-retainer",
  "mainEntityOfPage": "https://foundrysoft.co/blog/open-source-ai-agent-agency-sow-retainer",
  "image": [
    "https://foundrysoft.co/images/blog/open-source-ai-agent-agency-sow-retainer.webp"
  ],
  "datePublished": "2026-08-03",
  "dateModified": "2026-08-03",
  "keywords": "AI Agents, Open Source, Agency Ops, eve, Pricing",
  "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 the AI Agent We Use to Scope Work and Watch Retainers",
      "item": "https://foundrysoft.co/blog/open-source-ai-agent-agency-sow-retainer"
    }
  ]
}
```
