2026-10-13 · 26 min · mcp · agents · tool-calling · developer-tools · licensing
Why read this
Notabletop 60%Counts the 55 registered tools, works pixels to square feet to order quantity from the code, and shows where pencil-and-ink holds and where delete_shape slips.
- Original analysis
- Runs in a browser
- A new technique
Agents & harnessesAGPL-3.0-onlyPractitioner tool
How this was scored
- Is it new?
- 2 of 3: A real new idea, method or capability
- Can I trust it?
- 2 of 3: Measures key facts from files, code or configs
- Can I run it?
- 2 of 3: Open code or weights with real limits
- Will I understand it?
- 2 of 3: Mechanism from first principles with figures
- Can I act on it?
- 2 of 3: A concrete recipe, numbers or comparison
- Will it last?
- 1 of 3: Relevant for months
- Does it affect many?
- 1 of 3: A specialist community
- Only here?
- 2 of 3: A teardown or measurement few others did
Score 63 of 100, ranked 221 of 513 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored
A post from @RoundtableSpace made four claims about a repository I had not heard of. OpenTakeoff "measures rooms, counts symbols and calculates materials and waste, mainly for flooring estimates". It "runs in the browser without an account or file uploads, and exports to Excel and CSV". Its "MCP server exposes 53 tools for opening plans, setting scale, tracing rooms and exporting". And "agent results remain proposals until a person approves them."
That last line is why I opened it. Most tools that let a model touch numbers somebody will sign treat review as a UI nicety. A construction bid is a number somebody signs, and the error costs money in a way a chatbot transcript does not. One reply under the post put it well: "the error cost shows up in the bid, not the demo." The repository owner replied too, saying they use it every day for commercial estimating.
So I shallow-cloned Kentucky-ai/opentakeoff at 9624be8 (2026-10-10, MCP package 0.9.98) and read it: the scale code, the geometry, the totals, the symbol matcher, every tool registration in the MCP server, the review model on both sides, the export writers and the security notes. I did not run it. Everything below comes from the source, the repository's own docs and evaluation records, and arithmetic I did on those.
The short version: all four claims hold, and the repository is more careful than the post. The interesting parts are in the edges. The count of 53 is a count after two tools were pulled. "Proposals until approved" is a review state, not a quarantine, and one verb walks around it. "No uploads" is true of plans, not quite of page views.
- license
- AGPL-3.0
- branch
- main
- tests
- 370 files
- source
- 8.1 MB
- commit date
- 2026-10-10
by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded
local clone, 2026-10-13 at 9624be8 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount
shallow clone: counts describe the pinned tree, not the history

What a takeoff is, in one paragraph
A takeoff is the step before pricing a construction job: someone measures quantities off the drawings. For flooring, that means square feet of each finish (carpet, vinyl, tile) per room, linear feet of wall base and transition strips, a count of thresholds and drains, and an order quantity that adds cutting waste and rounds consumables up to whole buckets. The drawings arrive as PDFs, usually CAD exports with real vector linework, sometimes scans. Commercial tools for this exist and are seat-licensed. The README says that until OpenTakeoff there was no open-source takeoff engine at all. I can't prove a negative, but I didn't find one either.
OpenTakeoff is two front ends over one engine. The browser canvas in web/ is a React app that renders PDFs with pdf.js and keeps everything in IndexedDB. The MCP server in mcp/ is a stdio process that imports the same TypeScript modules from web/src/lib, so a ring an agent commits and a ring an estimator draws go through the same computeShapeMetrics and the same conditionTotals. That shared code is what makes the agent half more than a wrapper. The two halves still differ in one place the README admits: room detection paths are not identical, which is part of why One-Click is switched off right now.
Paper inches, image pixels, feet
Every coordinate in the system lives in one frame, and every tool description repeats it: "image px at render scale 2.0: PDF pt × 2, origin top-left, y down." A PDF point is 1/72 of a paper inch, the engine rasterises at 2×, so one paper inch is 144 image pixels. That number is the whole scale table. In web/src/lib/sheets.ts:
const PX_PER_IN = 72 * RENDER_SCALE; // 144
const arch = (inPerFt: number): number => (1 / inPerFt) / PX_PER_IN;
// '1/8" = 1\'-0"' → upp = 8 / 144 ≈ 0.0556 real feet per image pxupp, units per pixel, is the only number a sheet's scale carries. Architectural scales (1/16" through 3" to the foot), engineering scales (1" = 10' through 1" = 60') and metric ratios from 1:20 to 1:500 are all one line each. Metric is a display toggle; the stored quantity is always feet.
There are four ways to set it, and set_scale refuses unless you give exactly one: a standard label, a raw upp, use_detected to adopt the scale note read off the sheet, or calibrate, two points and the real distance between them. Calibration is one division:
// mcp/src/session.ts, setScale
const px = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]);
upp = feet / px;Area is where it gets unforgiving. closedMetrics in geometry.js is the textbook shoelace sum over the ring in pixels, and computeShapeMetrics multiplies that by upp * upp:
A scale error is a length error, and the area squares it. Calibrate on a dimension string that reads 24'-0" but type 24'-6", and every length on that sheet is about 2.1% long and every area about 4.2% large. Pick 1/4" on a 1/8" sheet and every area comes out at a quarter of the truth. The README says it bluntly: "Pixels × a wrong scale² is every number wrong at once."
The widget below runs those formulas on a made-up L-shaped room drawn at 1/8" = 1'-0", 336 SF as drawn. Leave the scale unset and measure_polygon refuses with the server's own message. Pick a standard scale, or calibrate on the 24'-0" dimension and mistype it, and watch the area move by the square of the mistake. The right-hand panel is the totals step, which the next section is about.
The scale is a gate, and the agent's scale is pencil
The README's list of rules an agent can rely on starts with this one: "Scale is a gate, not a default." It holds in the code. Every measure verb opens with the same check:
// mcp/src/session.ts, measurePolygon (and measureLine, measureSurface, …)
if (s.upp == null) throw new UserError(this.scaleGate(s));
// → "Set the scale for plan.pdf first — use set_scale (detected: 1/8" = 1'-0")."Two details in setScale are better than the README makes them sound. A scale the agent sets always lands with confirmed: false; quantities still flow, but takeoff_summary names every sheet whose scale nobody has confirmed, and only a person at the canvas clears the flag. And an agent cannot rescale a sheet that already holds measurements a person accepted. It gets "This sheet contains human-reviewed measurements. Recalibrate it in the canvas…" instead. Rescaling silently re-prices every shape on the sheet, so this is the right place for a hard stop.
The detected scale is a suggestion read off the page text, never applied. detectScale canonicalises every text item (curly quotes to straight, whitespace stripped) and matches it against the scale table with boundary checks, so 11/8" does not read as 1/8" and 1:500 does not read as 1:50. A hit in the title-block region (the right 45% of the sheet, the bottom half) wins. A single page-wide note is accepted. Several distinct scales and no title-block note returns nothing at all, because details on a plan sheet are usually drawn larger, and guessing between them is the error the gate exists to prevent. When a sheet carries several notes, set_scale says so, and a ring that sits inside a viewport whose note disagrees comes back with a warning.
The canvas adds the estimator's own check. Check a dimension (K) takes a printed dimension and grades the current scale against it; the README gives the bands as green within 1%, amber within 5%, red past that. Its working order for a real bid spends one line on it: "Ten seconds a sheet, and it's the only mistake that gets every number at once."
Waste lives in the order, not the measurement
The totals step is conditionTotals in web/src/lib/totals.js, and it is short enough to state completely. A condition is one finish, say CPT-1, carrying a waste percentage, a ×N multiplier for identical floors, a wall height and a list of supporting materials. For each condition the function sums its shapes by role (floor area adds, deducts subtract, wall traces add wall SF, linear runs add LF and, with a thickness, border SF, counts add EA), multiplies everything by N, and only then applies waste:
// web/src/lib/totals.js, conditionTotals
floor *= mult; wall *= mult; border *= mult; lf *= mult; ea *= mult; seam *= mult;
const total = floor + wall + border;
const w = 1 + waste / 100;
// ...
total_sf_net: round2(total * w),
sy_net: round2((total * w) / 9),Waste never touches the measured number. The canvas shows the measured square footage, the report shows both, and the order columns carry the allowance. A comment in the file owns up to a naming wart: the _net suffix means the waste-adjusted order quantity, the opposite of what an estimator calls net, and it stays because the CSV headers and the versioned report.v1 keys are public.
Supporting materials are where a careless implementation would double-count, and this one does not. A materials row is a coverage rate (square feet per gallon of adhesive, say) and a basis (area, linear, count or figured seam length), and the order is Math.ceil(basis / per). The basis is total, the multiplied but un-wasted area. Cut-off carpet does not get glued down, so dividing the wasted figure would buy adhesive for the trash bin. Whether every estimator agrees is a pricing question; the choice is consistent and documented.
The README's own report screenshot checks out against those formulas. WD-1 measures 743.4 SF at 10% waste: 743.4 × 1.10 is 817.74, shown as 817.7, and 817.74 ÷ 9 is 90.86 SY, shown as 90.9. VCT-1 is 103.5 SF at 5%, which gives 108.7.

Roll goods are the one place a percentage is the wrong model, and the engine knows it. Opt a condition into broadloom or sheet vinyl and rollgoods.js lays out lanes and seams per room. The README's example is the clearest argument I have seen for doing this properly: a 20-ft-wide room off a 12-ft roll seams once down its length, while the same square footage as two 10-ft rooms seams not at all, and "no factor on area can tell those apart." Seam tape and weld rod are bought by that figured seam length, which is why seam_lf is a materials basis of its own. One reply under the post made the same point.
Counting without a vision model
The post says it counts symbols. I expected an image model. It is geometry. symbolsweep.ts takes one marqueed example of a drain or a fixture and finds every other placement of the same cluster of vector segments:
- The fingerprint is every segment fully inside the seed rectangle, relative to its length-weighted centroid. Fully inside, so a marquee around a drain doesn't drag in the wall behind it.
- Candidates come from up to three anchor segments of distinct lengths, rarest on the sheet first. Every sheet segment of matching length votes for where the symbol's centre would be, under each of the eight transforms of a square (four rotations, with and without a mirror).
- Each candidate is scored as the length-weighted fraction of seed segments that find a sheet segment with both endpoints within 2 px. At
1/8"that tolerance is about 1.3 inches of real distance. - A score of at least 0.92 counts. Between 0.75 and 0.92 the placement comes back in
withheld, with its location, as a question.
That second band is the design decision I like most in the repository. A near-miss is neither silently counted nor silently dropped, and the server's initialize instructions spend a whole paragraph on it: "WITHHELD IS NOT A FAILURE — IT IS THE ANSWER." The same doctrine runs through the derivations. derive_transitions will not commit a carpet-to-tile transition between two rooms separated by a wall, because the real transition is a threshold in a doorway and nothing in a trace says where the doorway is. It reports the run, its length and the gap in inches, and leaves it to a person or a follow-up place_count.
Two more choices show the same temperament. The matcher never searches over scale. A symbol seeded on a detail drawn at 1-1/2" to the foot is 12 times the size of the same mark on a 1/8" plan, and the sweep resizes by that exact ratio only when both sheets have a scale set; otherwise it runs at 1:1 and says scale_assumed, "because an unknown ratio plus a zero count is not evidence of absence." And the candidate budget has a hard ceiling of 250,000 scored placements, with complete: false in the reply when the ceiling cut scoring short, so a truncated count is reported as a floor and not a total.
Two siblings cover the cases where geometry alone is the wrong tool. count_marks counts value-annotated device tags (an S1 with 200 CFM under it) from the text layer in one deterministic call. sweep_schedule_row starts from a schedule row's key, fingerprints the marker that tag is drawn in, and counts a match only when the row's own tag sits inside the marker, because drafting reuses one bubble shape for many marks.
Fifty-three, counted
The post's 53 is the README's number, and the README generates it. A script, check-tool-count.mjs, rewrites every <!--tool-count--> marker in the docs from the code and fails CI when prose drifts. So I went to the code.
mcp/src/tools.ts has 55 server.registerTool calls. staging.ts sorts the same 55 into four stages and a test enforces that the stages partition them exactly:
| Stage | Tools | What they do |
|---|---|---|
| setup | 11 | load a plan, sheet info, set the scale, the sheet graph, resolve a room tag, find schedules, read text, sheet structure, raw vectors, render a view |
| measure | 13 | propose a batch, two One-Click flood tools, polygon, line, wall surface, cut-out, counts, three sweeps or censuses, two derivations |
| revise | 24 | list, edit, delete, undo, proposal and condition-edit revisions, scope collisions, condition twins, annotations, verdict marks, four RFI verbs |
| handoff | 7 | summary, three exports and a DXF, import, re-apply rules |
gate.ts then withholds one_click and detect_rooms. The flood-fill engine behind them, the README's headline feature, is "being re-validated against a wider plan corpus", and on a default build the two verbs are not registered at all, so an agent never sees a tool it cannot use. 55 minus 2 is the 53. Set OPENTAKEOFF_ONE_CLICK=1 and you get 55; set OPENTAKEOFF_MCP_STAGED_TOOLS=1 and the server starts with the 11 setup tools plus an open_tool_stage verb that enables the rest stage by stage.
The README's own tool table lists 46 names, the two gated ones included. The nine it leaves out are propose_takeoff, revise_proposal, withdraw_proposal, propose_condition_edit, withdraw_condition_edit, scope_duplicates, scope_merge, count_marks and edit_annotation. Five of those are the proposal machinery the post's last claim rests on. docs/MCP_TOOL_INDEX.md and mcp/README.md name all 55; the front-page table is stale.
Staging exists because the descriptions are long. I extracted them from tools.ts: the 53 default tools carry a little over 62,000 characters of description before any parameter docs, and symbol_sweep alone is about 7,800. That text is where the rules live (trace on the innermost wall face, cross doors on the wall centreline, look at the overlay before trusting a total), and the repository has evidence that it works, which I come back to below. The agent manual is honest about the cost: staging is "context economy, not a permission boundary."
Pencil and ink
The review model is the reason to read this codebase. It has two actors and two kinds of mark. Agent work is pencil: every commit from the MCP server goes through one private method, Session.commit, which stamps the origin and forces the review flag:
// mcp/src/session.ts, commit()
if (origin?.actor === "agent") origin = { ...origin, reviewed: false };
if (origin) origin = this.stampProposal(origin); // attach the open batch
if (origin?.actor === "agent" && !origin.assignment)
origin = { ...origin, assignment: { source: "asserted" } };propose_takeoff opens a named batch with a required label and rationale ("Level 2 rooms per finish schedule A-601"), and every agent commit after it attaches to that batch. The agent hands the batch over by writing the takeoff_canvas.v1 JSON with export_takeoff; the estimator imports it in the browser, where agent shapes draw dashed, and a single Accept pill turns the whole batch into ink. Forty rooms become one decision. The estimator can still correct a vertex first, and when they do, stampEdit freezes the machine's original ring in origin.proposed_verts_norm beside the correction. The README is explicit that this pairing, the machine's guess next to the expert's fix, is the training signal its authors want to collect.
The project's own recording shows the whole loop. In a terminal on the left, Claude Code loads a VA medical centre finish plan, sets the scale the sheet declares, and one-clicks carpet in three patient rooms. Each export lands in the browser on the right as dashed pencil, and the operator accepts it. The recording leans on one_click, one of the two flood-fill tools the current build withholds, so today's agent would trace those rooms by hand instead.


Approval is a second, separate mark. The estimator's green APPROVED seal is minted only by the canvas's Approve tool. mark_verdict lets an agent sign its own work, but it takes no actor argument, so the only thing it can mint is a grey AGENT diamond. The marked-set PDF tallies the two on its cover: N estimator-approved · N agent-marked. A project manager can see at a glance how much of a set a person actually looked at.
Then I went looking for where accepted work is protected, because that is where a design like this usually leaks. It mostly holds. edit_shape refuses a shape with reviewed: true. So does cut_out, and so does scope_merge when the reviewed shape would be the one trimmed. withdraw_proposal removes only the pending shapes of a batch and reports how many accepted ones it left alone. delete_verdict refuses a human seal.
delete_shape has no such check. Session.deleteShape looks up the id and splices the shape out; there is no reviewed test anywhere in it, and none in the tool handler. That contradicts the import_takeoff description, which promises that reviewed shapes "arrive untouchable by agent verbs." The blast radius is smaller than it sounds: the MCP session is a separate process with its own copy, and the canvas import is additive, so deleting an accepted shape in the session cannot reach the estimator's canvas. It does reach anything the session exports afterwards, including the report and the marked set. I'd call it a one-line fix the project will want, not a hole in the model.
The other thing to understand is what proposal does not mean. conditionTotals sums shapes by role and never looks at the review flag. Pending pencil counts in takeoff_summary, in export_report and in the canvas's live counter from the moment it is committed; the review screenshot above shows a CPT-1 total that includes shapes still waiting for the Accept pill. The marked set does disclose it: when it is written from an MCP session, a credit line reads "Machine-traced via OpenTakeoff MCP" and counts the shapes still pending human review. And condition knobs are softer than geometry. propose_condition_edit exists and holds a waste or tag change pending, but edit_condition changes waste and multiplier directly with "No review gate — quantity config, not traced geometry." A careful agent will use the proposal; nothing forces it to.
So the post's claim is right about the state of the work and loose about the numbers. Agent results stay unreviewed until a person accepts them. They are not excluded until then.
The widget walks the same rules on three made-up rooms in one batch. Try the agent verbs on a room before and after the Accept pill, and watch which ones refuse.
One more boundary is worth stating, because the README almost states it. The review flag and the seal's actor are fields in a JSON file. The canvas trusts the actor field of an imported approval ("the actor field is the authority"), and the provenance module calls its own trust model "initials on a paper markup." Against the 53 MCP verbs, the pencil-and-ink line is structural. Against an agent that also has a shell and can write a JSON file by hand, as Claude Code does, it is a convention the estimator's eyes enforce. For a tool with no accounts I think that is a fair model; just know which of the two you are relying on.
What "nothing uploads" covers
The web app is client-only, and I found nothing that contradicts that for plans. ingest.js unpacks zips and wraps images into PDFs in the browser, store.js keeps PDF bytes and the takeoff in IndexedDB, and the Excel export is a hand-rolled 262-line SpreadsheetML writer (xlsx.js) that writes every cell as an inline string, so a finish tag that starts with = stays inert text instead of becoming a formula. The MCP server is a stdio subprocess that opens no socket; SECURITY.md is unusually clear about where its trust boundary sits and why a path argument writing where you asked is not a vulnerability.
Three things do talk to the network, and the repo discloses all of them. The bring-your-own-model agent panel sends your goal, the tool results and crops of the sheet (up to 1,024 px a side) to an endpoint you configure, and nothing if you configure none. The optional team mode syncs to your own Google Drive. The Contribute button posts a whitelisted, derived-only payload to a capture server you run. Because those endpoints are user-chosen, the CSP leaves connect-src open, and _headers explains the trade-off it accepts in a comment.
The fourth is not optional. web/index.html loads the Cloudflare Web Analytics beacon with the project's token, and the CSP allows its origin. It is a cookie-less page-view counter; it sees that a page loaded, not what plan you dragged in. But the README's "nothing phones home" is a statement about your plans, and anyone self-hosting a fork will want to delete that line.
How well an agent actually traces
With One-Click gated, an agent measures a room by reading wall strokes with get_sheet_vectors and drawing the ring itself with measure_polygon. Whether a model can do that well is the real question, and the repository has the most honest answer I've seen a project publish about itself, in evals/mcp-workflow-bench/plan-set/.
The set is three public plans: a VA healthcare finish plan, a VA clinic plan and a city ADU plan, with reference rings prepared from the PDF linework. The pass gate is strict on purpose, an IoU of at least 0.985 and every vertex within 2.5 px of the other ring, so a skipped one-foot door notch fails a room. Blind runs of one agent (Claude Fable 5.1, flat tool mode, One-Click off) passed 1 of 6 rooms on St. Cloud, 3 of 4 on Roseburg and 0 of 6 on Porterville against the corrected references, 4 of 16 by my sum. Read with the gate's strictness in mind, most of the misses are small: all six St. Cloud rings were within 0.4% of the reference area. The big ones are genuine estimator questions, a washer-dryer alcove on Porterville that was 17.47% off and a sink alcove at 6.39%, both of which the project lists as open questions rather than agent errors.

Two things in those records raised my opinion of the project. First, the blind runs found errors in the references before they found errors in the agent: the first version had traced a door leaf as a wall face and missed door notches, and the references were re-authored from the linework, with the old ones kept beside them. Second, a controlled trial isolates what the long tool descriptions buy. Same model, same stripped task with no tracing conventions in it, one change: the server build with the tracing rules packaged into the descriptions and the wiki. Rooms with every door crossed on the wall centreline went from 0 of 6 to 6 of 6, and median area error from 1.45% to 0.05%. Strict passes stayed at 1 of 6. One run per condition, one model, one plan, as the write-up itself says; it is evidence that the descriptions change behaviour, not a benchmark.
AGPL as of last Saturday
The repository was Apache-2.0 through commit 84c7f48e (tag apache-2.0-final) and is AGPL-3.0-only from 2026-10-10, three days before I write this. NOTICE is clear that the Apache code stays Apache for everyone who has it. New contributions go through a CLA that grants Kentucky AI the right to license them "under terms other than AGPL-3.0, including commercial terms." It is the usual open-core arrangement, stated in the open, and the README draws the same line around the data, where the engine is open and the models trained on the company's own estimating archive stay proprietary.
For most readers this changes little. Running the canvas or the MCP server for yourself is unaffected. Hosting a modified copy for other people means publishing your changes, and wrapping it in a product means reading the licence first. The reply under the post that flagged the relicensing was right to.
Would I hand it to an agent?
For flooring takeoffs, yes, with three habits. Confirm every sheet's scale in the canvas myself, because one wrong scale is every number wrong. Read the withheld arrays, because that is where the engine puts the questions it refuses to answer for you. And treat takeoff_summary as pencil totals until the batch is accepted.
The parts I would lift for other domains are the contract, not the flooring. Refuse instead of defaulting when a unit is missing. Return near-misses as questions with coordinates. Keep the machine's original guess frozen next to the human's correction. Make the human's mark a code path the agent cannot reach. If you build an MCP server that lets a model touch numbers a person signs, read mcp/src/session.ts and docs/AGENT_GUIDE.md before you design your own. The same question, what an agent may do and who it has to ask, comes up in OpenShell at the kernel boundary and in the argument about which constraints survive better models. The construction-data side of this site is KOLC+ and Gaussian splats in BIM; the browser-only, IndexedDB-backed architecture is the same one OpenCut uses for video.
To try it, the canvas is at opentakeoff.kentucky-ai.com with a sample plan, and the agent side is one line:
claude mcp add opentakeoff -- npx -y opentakeoff-mcpHow I checked
I cloned Kentucky-ai/opentakeoff with --depth 1 at 9624be8 (2026-10-10) and did not execute any of its code. I counted tools by listing every server.registerTool call in mcp/src/tools.ts (55) and diffing that list against the names in the README's table (46); the stage split is TOOL_STAGES in mcp/src/staging.ts and the gate is mcp/src/gate.ts. The description length is my own extraction of each tool's description string from tools.ts, with the shared coordinate sentence substituted and template expressions dropped, so treat 62,000 characters as approximate. Scale, area and totals formulas are from web/src/lib/sheets.ts, geometry.js, shapeMetrics.js, totals.js and Session.setScale; the report arithmetic is mine on the README's screenshot. The symbol matcher constants (2 px, 0.92, 0.75, three anchors, a 250,000 ceiling) are from web/src/lib/symbolsweep.ts. The review guards are from reading editShape, cutOut, scopeMerge, withdrawProposal, deleteShape and commit in mcp/src/session.ts, plus approvals.js, proposals.js and importTakeoff.js in web/src/lib; the delete_shape finding is from reading, not from a run. Network behaviour is from web/index.html, web/public/_headers, SECURITY.md and a search for fetch( across web/src. The evaluation numbers are the repository's own (evals/mcp-workflow-bench/plan-set/), not reproduced. The licence history is from NOTICE, CLA.md and the changelog. The source post has no thread and no video; the replies I quote are from its first page of replies.