Docs / Fabricators

Estimator widget

The instant-estimate form you embed on your own website, priced from your rates.

An instant-estimate form a fabricator embeds on their own website. It is branded as theirs, priced from their rates, and ships from their freight origin, but the parsing and pricing run on Gage.

Three parts: a dashboard where the fabricator configures it, the widget their customers use, and the intake service that turns a submitted design into something the pricing engine can quote.


#For fabricators: installing your estimator

1. Configure it. Sign in and open Widget in the operator nav. Set your company name, colour, logo, and freight origin, then choose how customers may submit a design.

2. Copy the snippet.

<script src="https://buildwithgage.com/widget.js"
        data-gage-key="wgt_your_key_here" async></script>

3. Paste it into your site where the estimator should appear — anywhere in the page body. The widget renders directly below the script tag.

To place it somewhere specific instead, point it at an element you already have:

<div id="estimator"></div>
<script src="https://buildwithgage.com/widget.js"
        data-gage-key="wgt_your_key_here"
        data-gage-target="#estimator" async></script>
AttributeRequiredPurpose
data-gage-key✓Your widget key. Identifies which fabricator's branding and rates to use.
data-gage-targetCSS selector to mount into. Defaults to just after the script tag.
data-gage-heightStarting height in px before the first auto-resize. Default 420.

It works on any site that lets you paste HTML: Webflow, Squarespace, WordPress, a hand-written page. No build step, no dependencies, nothing to keep updated.

Turning it off. The toggle on the settings page disables it everywhere immediately; visitors see a short "contact us directly" note instead of a broken form. Rotating the key invalidates the old snippet, so only do it if the key leaked somewhere it shouldn't be — you will need to update every site you pasted it into.


#How it works

fabricator's site
   └─ widget.js            loader: creates an iframe, keeps it sized
        └─ /embed/{key}    the estimator UI, branded from your config
             └─ POST /api/widget/{key}/estimate
                  ├─ intake      design → sqft, storeys, wall run, confidence
                  ├─ pricing     the Gage engine, pinned to your shop
                  └─ log         every request, whatever the outcome

#Why an iframe

The widget renders in an iframe rather than injecting into the host page. Fabricator sites carry arbitrary CSS and scripts; inline widgets get their inputs restyled, their layout broken, and their DOM read by whatever else is on the page. The iframe means your site's styling cannot reach the estimator and the estimator's cannot leak onto your site.

The tradeoff is that the widget cannot inherit your fonts. It picks up your logo, colour and name instead.

#Cross-origin setup

  • /embed/* is served with Content-Security-Policy: frame-ancestors *, so any site can frame it. X-Frame-Options is deliberately not set on that path, it has no "allow any origin" value, and some browsers treat an invalid one as DENY.
  • Every other route keeps X-Frame-Options: SAMEORIGIN.
  • /api/widget/* sends Access-Control-Allow-Origin: * and answers preflight. No cookies and no credentials cross that boundary: the widget key in the URL is the entire authorisation, and it grants nothing but "produce an estimate for this fabricator".
  • The widget key is public by construction, it sits in third-party HTML. That is what the per-IP rate limit is for (8 estimates per hour per browser per widget), since document intake runs a vision model on every call.

#Design intake

Each method reduces to the same facts, total floor area, storey count, exterior wall run, plus a confidence in them. Confidence is what decides between returning a price and routing to a person.

MethodHow it is readConfidence
Structured geometry (.json)Wall segments measured directly; floor area by shoelace0.95
IFCConverted to a building model: walls, openings, levels and floor areas as modelled. Up to 200 MB; the estimator compresses it before uploadas structured
glTF / GLBBounding box gives footprint and height; storeys inferred0.55
PDF / JPG / PNGVision model reads dimensions and area schedules0.3 – 0.9
Product lineThe fabricator's own stated dimensionsn/a, nothing to interpret
LinkFetched, then parsed as structured geometryas structured

Not parsed: RVT, DWG, DXF, SKP and STEP (export IFC or a PDF plan set from those). Reading wall semantics out of those containers properly needs a real BIM library, and a half-read model is exactly the input that produces a confident wrong number. They route to an engineer with an explanation rather than being guessed at.

#Model, cost and latency

Intake runs inside a serverless function with a hard 60s ceiling while a customer watches a spinner, so it is tuned for latency:

SettingDefaultEnv override
Modelclaude-haiku-4-5GAGE_INTAKE_MODEL
Effortlow (only sent to models that accept it)GAGE_INTAKE_EFFORT
Max output tokens1500—
Request timeout45sGAGE_INTAKE_TIMEOUT_MS
SDK retries0—

Retries are off deliberately. The SDK retries twice by default, and on a slow call that re-sends the whole PDF two more times, paying for up to three full reads and still returning a platform timeout.

This is a bounded extraction, not a reasoning problem, which is why a fast model is the right default. The confidence gate is what makes that safe: a weaker read reports lower confidence and routes to a person rather than producing a number nobody checked. If extraction quality disappoints, GAGE_INTAKE_MODEL=claude-opus-5 raises it without a deploy, at roughly 5x the token cost and materially more latency.

Every read records its model, token counts and estimated cost in the request log:

select
  extracted->'usage'->>'model'            as model,
  count(*)                                 as reads,
  round(sum((extracted->'usage'->>'est_cost_usd')::numeric), 2) as est_spend_usd
from gage_widget_requests
where extracted->'usage' is not null
group by 1;

#Confidence and review

The vision path starts from the model's own confidence, then deducts for each fact it could not actually find, no storey count, no dimensioned wall run, anything it listed as missing. Self-reported confidence alone is too generous.

Below the fabricator's threshold (default 70%, adjustable on the settings page), the request returns resolution: "review" with no price at all. Higher threshold means fewer instant estimates and fewer wrong ones.

#Flagged line items

Separately from whole-request review, an individual line the engine cannot resolve renders as flagged for engineer review with no number, and is excluded from the total:

  • Connectors, fasteners & hardware, always flagged. The engine prices steel by area; hardware is taken off per project once connection details are set. Showing a number here would be an invention.
  • Non-standard structural spans, flagged when wall runs exceed standard framing tables.

#Pricing

The estimate uses the existing engine: priceWithOperator and estimateTons, the same functions POST /api/v1/quotes runs.

It does not call that endpoint, deliberately. API v1 routes a quote to the lowest landed cost across the whole network, which is right for the marketplace and wrong here: a widget on one fabricator's site must quote that fabricator. So the engine is invoked pinned to the embedding shop.

Capacity applies. A fabricator who has paused intake, or whose job-size ceiling the project exceeds, returns review rather than a price.

Tax is applied to the priced subtotal at the configured rate, and the jurisdiction is recorded on the estimate.


#3D steel frame

After the price breakdown, a quoted estimate offers a View in 3D step: the panelized steel frame - studs at 24 in o.c., top and bottom track, headers over openings, jambs, sills and cripples - rendered in the browser and rotatable.

estimate (quoted)
  └─ planFrame(input)        members + flags + summary   -> in the estimate response
       └─ GET /api/widget/{key}/frame/{request_id}
            └─ buildFrame()  same plan, encoded as GLB    -> fetched by the viewer

Deterministic geometry, not generation. lib/frame/ places every member by rule from wall centrelines, spacing and openings. Nothing is inferred and no model is called. The seam planner is the same one the fabrication package uses, so the panel breaks shown are the breaks a shop would cut.

Nothing is stored. The GLB is regenerated on request from what the log already holds about the design. Same input, same bytes, so the response is cached immutably. Generation is single-digit milliseconds for a house and well under 100 ms for a large two-storey building with an opening every 8 ft - measurable per request via the x-gage-frame-ms header.

All rendering is on the customer's device. Three.js is loaded only when the 3D step is opened, so the widget's first paint stays light on a fabricator's site.

#What the outline is, and is not

The frame is only as real as the geometry that came in, and the viewer says which:

IntakeFootprintWhat is drawn
Structured geometry JSONmeasuredThe submitted walls and openings, as given
PDF / image, dimensioned plantracedThe outline and openings the vision model read off the plan, after validation
glTF / GLBboundingThe model's bounding rectangle; no interior walls or openings
PDF / image without a usable outline, product linesynthesizedA rectangle solved from the priced floor area and wall run

The read is hierarchical. The prompt has the model classify first — building type, above-grade storeys, basement, roof style — then measure, then report geometry. Deciding what the building is before measuring it produces better reads, and two of those classifications matter beyond drawing: structure_type feeds the engine's pricing type, and a basement is excluded from CFS storeys and drawn only as a foundation slab.

Geometry is asked for as printed, not computed. The perimeter is requested as a walk of dimension strings along the exterior walls — E 26'-6", N 24'-0" — because that is what a plan prints, and a model reads a dimension string far more reliably than it computes corner coordinates. lib/intake/traverse.ts turns the walk into a ring and rejects it if it does not return to its start within a foot or 2% of the perimeter: a walk that fails to close is a misread, caught before anything draws. XY corners are accepted only as a fallback.

The frame is drawn as cold-formed steel. Studs, jambs and cripples are C-sections — web across the wall depth, flanges in the wall faces — track is a U the studs sit into (bottom opens up, top opens down), rafters and joists are C's laid along their run, headers are box sections. Exterior and bearing walls use a 5.5 in web, partitions 3.625 in, flanges 1.625 in; sheet thickness is drawn at about a quarter inch so the section reads on screen. Every opening is a gap in the stud line with king and jack studs, a header over it and a sill under a window, and the summary counts the headers. Materials are galvanized grey with a metallic sheen.

Interior walls are drawn, and shown as unpriced scope. The read asks for each interior wall anchored to the perimeter walk — which leg it starts from, how far along, which way it runs, its printed length, whether the plan marks it bearing, and its doors. Each is placed only if it lies inside the outline; bearing walls draw as stud steel, partitions draw lighter. Because API v1 prices the exterior envelope, interior footage appears on the estimate as a flagged line with its linear feet — priced by the fabricator on review — rather than folded into the total or left out silently.

The roof comes from the elevations, not from a label. Pass 1 traces the building's silhouette from every elevation view in the set — front and back give its height along the width, left and right along the depth — and the massing is the volume that fits under both. That reconstruction has no roof types in it: an A-frame comes out as an A-frame because its end view is a triangle to a low knee wall and its side view is a rectangle to the ridge; a gable, a hip and a gambrel come out in the proportions drawn. Each view also says what it faces (gable end, roof plane, walls and roof); a roof-plane view is snapped to a straight ridge line, front and back are reconciled by keeping the lower of the two at every point (a walkout basement makes the back view taller below the main floor), and the two pairs of views are matched to the plan's long and short sides by their relative ridge heights rather than by trusting the sheet's orientation. A printed ridge height overrides the traced one. Only a set with no elevation views falls back to a stock roof form, and then the preview says the roof is estimated. Pass 1 runs on Sonnet 5 by default (GAGE_MASSING_MODEL): on the A-frame test set Haiku drew the right form with the wrong proportions, Sonnet read the printed ridge and the footprint dimensions; the difference is about six cents per read.

Two outputs, two accuracy bars. The 3D preview and the price are different deliverables and are read separately. Pass 1 — massing (lib/intake/massing.ts, lib/frame/massing.ts) asks one question of the drawings: what is the building's outer shape? It returns the outermost wall boundary as relative corners, the roof form, and whether each upper floor is the same size or smaller, and ignores dimension text, openings and interior walls entirely. The outline is sized from an overall dimension if one is printed, otherwise from the floor area the survey read, and extruded into a solid — one prism per storey, a simple roof form over the top storey, a block below grade for a basement. It is shown as the default preview under the heading "Your building" and framed as approximate. The only way Pass 1 fails is if the outer boundary itself cannot be identified; then the preview is a rectangle of the stated area and says so. Pass 2 — take-off is the per-sheet read described below: the perimeter walk of printed dimensions, the openings, the interior walls, validated and gated as before. It targets structural sheets: when the set has framing plans, only those are read, one per level; a full architectural set with none falls back to the dimensioned floor plans. Its output is the steel frame, one toggle away from the massing in the viewer, and the measured wall run that feeds the price. Both passes run in parallel after the survey, and neither can fail the other.

Consistency checks run before the frame. lib/intake/consistency.ts cross-checks the whole set and reports pass / fail / unverified with the specific items, ahead of the frame in the widget ("Plan checks"), in the estimate response and in the inspection JSON: (1) every door and window mark in a schedule is located on a plan or elevation, with the schedule's count against the frame's openings; (2) decks, porches, patios, terraces and garages listed in the set are named, and the traced ground-floor outline is compared to the conditioned ground floor so an outline that swallowed the deck is flagged; (3) the level areas, the stated total, gross vs conditioned figures, the same label printed with two values on different sheets, and the traced footprint are all listed and any disagreement is flagged rather than one number chosen; (4) each level states where its outline came from — traced from its own sheet, derived from the elevation silhouettes at its height, the foundation plan, or copied — and a copied level is a failure. A level without a usable plan now takes its extent from the elevations at its own height (extentFromElevations) instead of copying the level below.

Inspection: the data between reading and 3D. Every estimate's intermediate data is inspectable at GET /api/widget/{key}/inspect/{request_id}: the survey's facts, Pass 1's massing input and summary, Pass 2's levels with each wall's coordinates, heading and length, each opening's position, size and the header the panelizer frames over it, the planner's flags, the model's raw per-sheet answers before validation, the notes and the usage — each tagged with the pass that produced it. ?format=svg returns the same data as a top-down sketch, one panel per level with openings as gaps and the massing outline overlaid, to hold against the plan sheet without 3D in the way. The embed shows Inspect: sketch / JSON links when opened with ?debug=1. Locally, npx tsx scripts/inspect.mts plan.pdf runs the whole read and writes inspection.json, sketch.svg and sketch.png. scripts/test-plans.ts generates four vector test sets that add one variable at a time (a rectangle with one door; plus a window; plus a second storey and an elevation sheet; a 14 ft opening wider than a standard header) into tests/fixtures/plans/, so a failure can be pinned to the feature that breaks rather than diagnosed against the worst-case set.

The PDF is prepared page by page before any model sees it. Handing the model API a heavy architectural PDF failed two ways: its own rasteriser could take forty seconds and then answer "Could not process PDF", and its text extraction flattened every sheet into a blob, losing where each dimension string sat. lib/intake/pages.ts extracts every page's text layer with coordinates in-process, sums the dimension strings that share a line into chains (a chain whose total matches a printed overall is a complete wall run — that is how a person tells the exterior from a room span), and renders the drawing sheets — plans, elevations, sections, foundation, framing — to JPEG in an isolated child process with embedded raster images left out; a page that crashes the renderer goes as text only. Each call then receives the pages as text-plus-image blocks under one cache breakpoint, and the raw PDF is never sent. On the A-frame test set this took the ground-floor walk from unreadable to 382" x 661", closing exactly, in about 40 seconds end to end. Inch-dimensioned sheets are read as such (walk, openings and interior walls alike), an undimensioned wall or two is solved from the walk closing, and when a ground-floor sheet still cannot be read the foundation plan stands in for its outline — shown on the steel frame as a warning, never as a traced plan.

The read is two stages, so each sheet gets read properly. A single call asked to summarise a twelve-page set skims it — on a real plan set it returned under 800 tokens and never found the perimeter dimensions. lib/intake/vision.ts now makes one small survey call (building type, storeys, basement, roof, areas, and which sheet is the floor plan for which level), then one call per floor-plan sheet, all in parallel, each reading a single level with full attention and answering in compact arrays. The PDF is prompt-cached by the survey, so the sheet calls pay a tenth for input; on a 31,700-token set the whole read is about 7 cents. A sheet call that fails or runs out of time costs that level its own outline and nothing else — the estimate still returns, and the notes say which sheet was not read. Every call is counted in usage (calls, cache traffic, elapsed time, estimated cost), including reads that failed.

Each level is traced from its own floor-plan sheet. The read returns one entry per plan sheet, bottom to top: the basement, the ground floor, each floor above it, a loft. Every sheet carries its own perimeter walk of printed dimension strings, its own openings and its own interior walls, and lib/intake/outline.ts validates each on its own — the walk must close, the ring must not cross itself, and the enclosed area must agree with what the same read stated for that level, within 35%. Sheets are then stacked in order, each sat onto the outline below it (a set-back upper floor is slid to the edge or centre that puts the most of it over the floor beneath). The basement plan becomes the foundation outline, drawn as concrete walls below grade. A sheet that fails is not drawn: an upper level inherits the outline beneath it with the reason in the notes; a ground floor that fails sends the whole frame back to the schematic rectangle — a confidently wrong outline of someone's house is worse than a square. The 3D subtitle says how many levels came from their own sheet.

The outline never affects pricing confidence; it exists only for the 3D step.

A synthesized frame carries an amber note: it shows the steel the estimate priced, not the customer's plan shape or window positions. A traced frame carries a blue note asking the customer to verify it against their drawings.

#Roof

The vision read also reports roof type, pitch, overhang and any roof windows from elevation, section or roof-plan views. lib/frame/roof.ts frames it over the top storey: rafters at spacing, a ridge and ceiling joists for a gable; hip rafters, commons and jack rafters for a hip (collapsing to a pyramid on a square plan); two-pitch rafters with a purlin for a gambrel; single-slope rafters for a shed; joists for a flat roof. Roof windows are framed with trimmers and doubled rafters, and the rafters they interrupt are cut rather than drawn through.

The read roof is priced, not just drawn. The engine's roof factor moves tonnage, so a hip at 8:12 read off the elevations is quoted as that. When no roof can be read, the estimate falls back to a 6:12 gable and the frame draws that assumption in the flagged material, so it cannot pass for the customer's roof.

A roof over a non-rectangular plan is framed over the bounding rectangle and flagged as such. Roof windows that do not fit the plane the drawings put them on are dropped with a reason.

#Flagging, the same rule as pricing

Anything the engine cannot resolve is still drawn - in a translucent amber material - and listed under the viewer with the reason:

  • long_unbraced_run - a wall over 60 ft without an intersecting wall
  • tall_wall - over 12 ft; stud section is designed, not stock
  • header_span_exceeds_standard - an opening over 12 ft
  • panel_split_conflict - no seam plan keeps an opening whole inside a 12 ft panel
  • opening_unpositioned - an opening with no position along its wall; drawn without it rather than guessed
  • roof_assumed - no roof read from the drawings; the priced gable is drawn flagged
  • roof_over_bounding_rect - non-rectangular plan; roof framed over its bounding rectangle
  • roof_opening_unplaceable - a roof window on a side with no plane, or off the plane
  • roof_gambrel_pitches_assumed - one pitch given; the steeper lower slope is assumed
  • roof_style_approximated - an A-frame drawn as a steep gable, or a mansard as a steep hip

A model that refused to draw a wall because of a header decision would hide exactly the part a customer most needs to see.


#Request log

Every submission is written to gage_widget_requests whatever the outcome, quoted, review, rejected or error, with input type, confidence, what was extracted, the resolution and its reason. That is the point of it: where automated parsing breaks down should be measurable rather than anecdotal.

-- Where is intake failing, and how confident is it when it succeeds?
select input_type, resolution, count(*), round(avg(confidence)::numeric, 2) as avg_confidence
from gage_widget_requests
group by 1, 2
order by 3 desc;

Caller IPs are stored hashed. Analysing where parsing breaks never requires knowing who submitted a plan.


#Setup

Migration: supabase/migrations/010_widget.sql creates gage_widget_configs and gage_widget_requests.

Storage: a private widget-uploads bucket, created automatically on the first large upload. Files land there only for the seconds between upload and intake, and are deleted once read.

Environment: ANTHROPIC_API_KEY for document intake. Without it, document uploads return a clear "not configured" error and the other intake methods still work.

Local testing: npm run build && npx next start -p 3100, then point a test page's script tag at http://localhost:3100/widget.js.

#Files

PathRole
public/widget.jsThe embed loader fabricators paste in
app/embed/[key]/The hosted widget UI
app/api/widget/[key]/configPublic branding/config for the widget
app/api/widget/[key]/estimateIntake → pricing → logged estimate
app/fabricators/widget/Fabricator configuration dashboard
lib/intake/Geometry parsing, vision extraction, confidence
lib/widget/Config, estimate assembly, CORS, rate limiting
lib/frame/Panelization to 3D geometry and the GLB writer
app/api/widget/[key]/frame/[id]Regenerates and serves the frame GLB
app/embed/[key]/FrameViewer.tsxLazy-loaded Three.js viewer

#Current limits

  • Rate limiting is in-process. Behind more than one instance it needs Redis; the interface is one take() call in lib/widget/rateLimit.ts.
  • Uploads are capped at 15 MB. Files over 4 MB cannot travel in a serverless request body, so the browser uploads them straight to the widget-uploads bucket via a signed URL and the estimator is handed the storage path; smaller files go inline and skip the handshake.
  • A large PDF still has to be read within the 60s function limit. If dense plan sets start timing out, that shows up as a 504 and the fix is splitting intake into a queued job rather than raising the cap again.
  • Uploaded files are deleted as soon as intake has read them, nothing is stored, so a submission cannot be re-examined later. Only the extracted numbers are kept.
  • Estimates are not persisted as quotes and cannot be accepted. This is estimate-only by design at this stage; there is no checkout.
  • The widget assumes a 9 ft ceiling and a 6:12 gable roof when the design does not state them, which affects tonnage. Structured geometry that specifies them is priced on the real values.