An AI-native procurement portal for contractors buying from LBM (lumber & building materials) suppliers. Plan → Quote → Order → Invoice, on one board.
This runs in two modes, and which one you are in changes what every number on screen means. The app says which, on every screen, in the badge under the dealer's name.
Wired (
GABLE_API_URLset). The portal talks to a realgableERP. Contractors sign in with real credentials, browse the dealer's real catalog at their own account pricing, and place orders that land in the dealer'sorderstable. Order status is read back from the ERP. See Wiring it togable.Standalone (
GABLE_API_URLunset — the default).src/core/sim/is a simulator that plays the supplier: it prices lines, runs a quote desk, ages orders through a lifecycle, and issues invoices. Pricing is real logic against real rules — the counterparty is fictional. Every screen is labelled "Local simulation". Do not put a standalone deployment in front of real contractors: the prices are invented.Either way, these remain true:
- The portal has no database of its own. Board state — projects, drafts, scope, customer quotes, signatures — lives in the browser's
localStorage. Close your browser profile and it is gone. Two people cannot share a board. Wired, the things that matter (catalog, pricing, orders, status) live ingable; the board around them does not.- Three things are portal-local by design, even when wired, because
gablehas no endpoint for them: the pre-quote Plan stage, the customer-quote markup / labour / overhead, and the e-signature. The UI labels each one where it appears. A signed customer quote is alocalStoragerecord and would not survive a dispute.- The dealer is fictional in standalone. "Gable Supply" is a default in
src/core/domain/config.ts; the demo contractor is "Summit Ridge Builders". No real dealer's or customer's data is in this repository. Wired, the dealer name comes fromgable's ownPortalConfig.ROADMAP.md lists what is not built, without softening it — including every
gableendpoint this portal needed and did not find.
Three names refer to the same thing, and all three are in use:
| Name | What it is |
|---|---|
gable-portal |
This repository, and the npm package name. The ecosystem's name for the product line. |
| GableNow | The product's own brand — what the UI says, what GableMark.tsx renders, and the prefix on its environment variables (GABLENOW_ADMIN_TOKEN). |
| LumberNow | The former name. The project was renamed before this migration; the old repository was futurebuildai/lumbernow. You will still see it in ecosystem planning documents. |
They were not consolidated because the brand and the repository slug are answering different questions. This table exists so nobody has to guess.
The premise: contractors abandon dealer e-commerce because it is a retail shopping cart wearing a trade-account badge. So there is no catalog-first funnel and no cart. There is a Procurement Board.
A project is the site — "Wilson Custom Home". It has an address, a client, and no stage. An order is a procurement unit inside it — "Framing package", "Roofing", "Trim" — with one delivery date, one fulfillment method, and its own stage. Orders are the cards, which is why every card has exactly one unambiguous position on the board.
| Stage | What happens |
|---|---|
| Plan | Draft orders. Lines are priced live by the contractor's account terms — tier baseline, negotiated category discounts, locked contract SKUs, volume breaks. |
| Quote | Push to the dealer's quote desk. Required when the scope contains a special-order line the ERP cannot price. Then optionally build a contractor-branded customer quote — markup, labor, overhead — shared with the homeowner over a one-time link they can review and sign. |
| Order | Converts to a supplier sales order. Delivery or will-call tracking, reschedules, lead times. |
| Invoice | AR, including offline counter-sale invoices. Saved payment methods, few-click payment. |
Alongside that:
- An AI assistant (
src/core/ai/) that drafts orders from natural language and photographs. It calls the same guarded action functions the buttons call, so an action the model takes is indistinguishable from one the contractor took and cannot bypass a permission gate. Two rules are enforced in the system prompt and in the tool layer: never invent a quantity, never invent a price. - A dealer admin console at
/admin.html— branding, terms, feature flags, and the Anthropic credential. It is a separate bundle so admin code never reaches the contractor bundle. - A team and permission model — owner, purchaser, field, A/P — where a refusal names who can do the thing rather than just saying no.
The full walkthrough, with 31 captured screenshots, is docs/user-guide.md.
The Gable ecosystem is a set of repositories under
FutureBuildAIinc:
| Repo | Role | License |
|---|---|---|
gable |
The host — the LBM ERP commons that dealers run | Commons / Surface / Connector / Docs |
gable-sdk |
The plug-in seam for third-party apps | Connector |
gable-ai-lm |
Load-management satellite (dispatch, routing, compliance) | Community Source |
gable-portal (this repo) |
Contractor-facing satellite — the buy side | Community Source |
openlbm |
The licensing Standard itself | — |
gable-docs |
Architecture and reference documentation | Docs |
gable is the dealer's system of record. This portal is the contractor's
window into it, and as of this change the window is real: set GABLE_API_URL
and the spine — auth, catalog, pricing, order submission, order status — runs
against gable's /api/portal/v1/* API. See below for exactly what is wired
and what is not.
gable also contains a portal-shaped surface of its own under
app/src/pages/portal/. Which repository is the contractor portal remains an
open architectural question; what is now settled is the seam between them —
backend/internal/portal/ is the contract, and this repository is a client of
it.
# 1. gable, on :8080, against its own database
cd ../gable/backend
DATABASE_URL="postgres://gable_user@127.0.0.1:5432/gable_db?sslmode=disable" \
PORTAL_JWT_SECRET="$(openssl rand -hex 32)" \
INSECURE_COOKIES=true PORT=8080 go run ./cmd/server
# 2. the portal, pointed at it
cd ../../gable-portal
GABLE_API_URL=http://127.0.0.1:8080 GABLE_ALLOW_INSECURE_COOKIES=true npm run devSign in with a customer_users row from gable (the seeded demo is
demo@kelbrook.ca / password). The badge under the dealer's name turns green
and reads Live — .
| Source of truth | How | |
|---|---|---|
| Sign-in | gable |
POST /api/portal/v1/login. The JWT is an httpOnly portal_token cookie the browser holds and this app cannot read. A 401 signs you out; it is never retried. |
| Catalog | gable |
GET /catalog replaces the seeded catalog wholesale. |
| Pricing | gable |
customer_price from the ERP's own waterfall (contract → promotional → tier → retail). No tier table on this side. |
| Projects | gable |
GET /projects replaces the seeded projects. |
| Order submission | gable |
The board's Plan → Order drag clears the ERP cart, adds this order's lines, and checks out. A real orders row appears in the dealer's database. |
| Order status | gable |
A conditional poll of GET /orders with ETag / If-None-Match and an X-Portal-Latest-Change cursor, refined by GET /deliveries only when something moved. The simulator's scheduler is stopped — a real ERP drives state, not a timer. |
| The quote desk | gable |
The Quote column sends the scope to POST /quotes — including special-order lines the catalog cannot express. A person at the dealer prices it; the price is read back and written onto the lines. Accept/decline are POST /quotes/{id}/accept|decline. |
| Cancelling a placed order | gable |
POST /orders/{id}/cancel. A refusal (already cancelled, fulfilled, goods on a dispatched route) puts the card back in Order and shows the dealer's own reason. |
| Rescheduling a delivery | gable |
POST /deliveries/{id}/reschedule — a request, not a write. It answers 202 with applied: false and the dealer's schedule is untouched; the UI says "requested" and never "moved". |
| Job history | gable |
project_id on the order plus GET /orders?project_id= lands the customer's existing dealer orders on the job the dealer filed them against. Orders with no job are listed, not guessed at; PUT /orders/{id}/project files one. |
| Lead time, volume breaks, aisles | gable |
lead_time_days (nullable — see below), GET /catalog/{id}/volume-breaks, and GET /catalog/categories. |
| Invoices, deliveries, dashboard | gable |
Available through the client (src/core/gable/client.ts); the board reads status and deliveries today. |
A null lead_time_days means the dealer has published none. It is not
folded into 0 and not replaced with a guess: the product reads "no lead time
published" and the lead-time-vs-delivery-date warning stays silent, because a
crew gets booked around that number.
| Why | |
|---|---|
| The Plan stage | A draft scope is the contractor's working notebook. The dealer never sees it, and gable has no draft resource. |
| Customer quote — markup, labour, overhead | This is the contractor's own margin. The dealer must never see it, and gable has no endpoint that would carry it. |
| E-signature | A localStorage record. It would not survive a dispute. Stated on the homeowner's own screen, not only in the contractor's. |
Those three, and what genuinely remains missing on the gable side, are listed
in ROADMAP.md §1.
gable sets its session cookie HttpOnly; Secure; SameSite=Strict; Path=/api/portal. A SameSite=Strict cookie is only ever sent on same-site
requests — so a portal on portal.example.com calling erp.example.com
directly would have the cookie withheld on every request and see 401 forever,
a failure that looks exactly like a wrong password.
So the portal's own host proxies /api/portal/* to GABLE_API_URL
(server/gable-proxy.ts). Everything is same-origin, the cookie's path matches
unchanged, and — the part that matters for deployment — GABLE_API_URL never
reaches the browser and is never baked into the bundle. It is read at run
time. The client is told only that an ERP is configured, via a flag injected
into the document before first paint (so there is no frame in which a simulated
board is shown as a live one).
Requires Node ≥ 20.19 (CI and the maintainers use 22) and npm.
git clone https://github.com/FutureBuildAIinc/gable-portal.git
cd gable-portal
npm install
npm run dev # http://localhost:5173That is genuinely all of it. There is no database to provision, no migration to
run, no .env required, and nothing to log into — the app boots with demo data
already in place: four projects, eight orders across all four stages, and a
catalog priced through the same engine the live app uses.
To enable the AI assistant you need an Anthropic API key. Without one the assistant is disabled and looks disabled — it is not faked. Two ways, and the choice matters:
cp .env.example .env
# then set ANTHROPIC_API_KEY=sk-ant-...or paste a key into the dealer admin console at /admin.html, which stores it
server-side in .gablenow/secrets.json (mode 0600, gitignored, never returned
to a client). The admin console requires GABLENOW_ADMIN_TOKEN to be set and
refuses every request when it is absent — there is no open-by-default mode.
Generate one with openssl rand -hex 32.
ANTHROPIC_API_KEY is deliberately not VITE_-prefixed, so Vite never inlines
it into the client bundle. It is read in vite.config.ts and handed to the
same-origin proxy in server/claude-proxy.ts.
Every one of these is real; run npm run <name>.
| Command | What it does |
|---|---|
npm run dev |
Vite dev server |
npm run build |
tsc -b && vite build — the pre-flight check |
npm run typecheck |
tsc --noEmit |
npm test |
Vitest, once |
npm run test:watch |
Vitest, watching |
npm run test:coverage |
Vitest with a v8 coverage report in coverage/ |
npm run check |
Biome format + lint, writing fixes |
npm run lint |
Biome check, not writing — this is what CI runs |
npm run preview |
Serve the production build with Vite |
npm run build:server |
esbuild-bundle the production host to dist-server/serve.mjs |
npm run serve |
Run that bundle. HOST defaults to 0.0.0.0 so a container actually receives traffic — set HOST=127.0.0.1 for a local-only run. |
Four of those — typecheck, lint, test, build — are the gates CI enforces
and the gates a PR must pass. Five more exist and are not in CI, because each
boots a real server and drives it with a real browser:
| Command | What it catches |
|---|---|
npm run a11y |
axe over every screen, both apps, at phone width |
npm run security |
boots a real dev server and attacks it |
npm run predeploy |
the same, against the production host in server/serve.ts — which shares almost no implementation with the dev server, so a defence proven for one says nothing about the other |
npm run e2e |
drives the product as a contractor against a production build; fails on any uncaught error or 404 |
npm run contrast |
measures rendered contrast ratios |
npm run guide |
rebuilds, walks the app with Chrome, and rewrites every screenshot in docs/user-guide.md |
Run them before a release. npm run guide needs a locally installed Chrome;
the rest use Playwright.
src/core/is framework-free.
All domain logic, pricing, totals, the stage machine, the stores, and the AI
tool layer are plain TypeScript with no React import anywhere. src/ui/ is the
only place that knows a framework exists. This is enforced three ways: a Biome
noRestrictedImports override, a test
(src/core/__tests__/architecture.test.ts) that walks the tree and fails on a
banned import, and the fact that server/ imports from src/core/ directly.
It exists so the React layer is replaceable — the planned direction is web components a dealer can drop into their own site — without rewriting the part that knows what an order costs.
src/core/ framework-free
lib/money.ts integer cents, everywhere. Never floats.
domain/ Project, Order, ScopeItem, totals, the stage machine
supplier/ THE SEAM. port.ts is the interface; sim.ts is one impl.
sim/ the SIMULATED supplier: pricing engine, quote desk, lifecycle
gable/ the REAL supplier: HTTP client, schema, mapper, pricing,
connection lifecycle. The other impl of supplier/port.ts.
actions/ the ONLY mutation path — buttons and the AI both call these
selectors/ read models (board, order detail, AR, tracking)
stores/ tiny observable stores + localStorage with cross-tab leases
ai/ prompt, session, and the tool layer bound to actions/
src/ui/ React 18, Tailwind 4, react-router
src/admin/ the dealer console — a separate Vite entry point
server/ host-agnostic (req, res) handlers: Claude proxy, admin API,
and the same-origin gable proxy
scripts/ the five out-of-band audit gates + the guide capture harness
Dockerfile multi-stage: vite + esbuild build, then a node:22-alpine host
.do/ an example DigitalOcean App Platform spec
src/core/supplier/port.ts is the load-bearing addition. sim/pricing.ts has
carried a comment since M1 saying that when a real ERP connects, the simulator
is replaced by an API call returning the same PriceQuote and nothing in
domain/ changes. That is now a tested claim rather than a design intention:
two implementations satisfy one interface, actions/ calls neither directly,
and the 451 pre-existing tests pass unchanged through the new call frame.
CLAUDE.md is the long-form architecture document — roughly a thousand lines
of why, written as the milestones landed. It is the best thing to read before
changing anything in src/core/.
docker build -t gable-portal .
docker run -p 8080:8080 -e GABLE_API_URL=https://erp.example.com gable-portal.do/app-portal.yaml is an example DigitalOcean App Platform spec, modelled on
gable/.do/app-demo.yaml. Replace the repo, the domain and GABLE_API_URL
before applying. It has not been applied to a live account — the routing,
health-check and env-scope choices in it are reasoned, not observed.
Three things about the container are worth knowing before you debug it at 2am:
- It binds
0.0.0.0.server/serve.tsused to defaultHOSTto127.0.0.1, which is correct on a laptop and fatal in a container: App Platform's router and health checker live outside the container's network namespace, so a loopback bind starts cleanly, logs cheerfully, passes every local smoke test, and receives no traffic at all. preserve_path_prefix: trueis required on the route. Without it App Platform strips the matched prefix and/api/portal/v1/catalogreaches the container as/v1/catalog; the proxy then forwards a truncated path andgable404s every call, with nothing in either log saying why./healthzdoes not touch the ERP. Agableoutage must not also take the portal out of rotation — the contractor should keep the board and an honest "Supplier unreachable" badge, not a 503 from the router.
Nothing is baked in at build time. There is no ARG for GABLE_API_URL and
nothing VITE_-prefixed anywhere, so the same image is promotable from staging
to production and contains no secret and no ERP address.
GABLE_ALLOW_INSECURE_COOKIES=true strips Secure from the session cookie on
its way back through the proxy. It is needed only when the portal is served
over plain HTTP — npm run dev, or a container smoke-tested before a TLS
terminator is in front of it. Never set it on anything reachable over a network.
This is a B2B ordering surface, so a formatting bug is a commercial bug. Three rules the codebase does not bend on:
- All money is integer cents.
src/core/lib/money.tsis the only boundary, and it converts via the decimal string (Number(\${dollars}e2`)) rather thandollars * 100, because binary floating point turns1.005 * 100into100.49999999999999` and rounds a half-cent the wrong way. - One definition of every total.
orderTotals()insrc/core/domain/totals.tsis what the board card, the order page, the customer quote and the AI all read, so a total cannot disagree with itself between two surfaces. - An unknown total is not zero. An order whose lines are all awaiting the
quote desk sums to
$0.00, and rendering that confidently next to "1 item still needs dealer pricing" is a lie about what the order is worth.hasKnownSubtotal()exists to make the UI show "—" instead.
Code is LicenseRef-OpenLBM-Community-Source-1.0. Prose and repo
plumbing are LicenseRef-OpenLBM-Docs-1.0.
Community Source is source-available, not OSI open source. Read it, modify it, build it, evaluate it, redistribute it — everyone, always. Run it in production free and immediately if you are a Community Member: fewer than 50 locations, not controlled by an entity above $1B revenue. That is most independent dealers and effectively every contractor. Large chains, national buying groups, and anyone reselling it as a hosted service need a commercial license. Each released version converts to AGPL-3.0-only five years after its own Change Date.
LICENSE-MAP.md has the per-path table, the reasoning, and the
one thing you should know about public/images/brands/ before you redistribute
this. The license texts are drafts pending counsel; the canonical Standard
is openlbm.
The trademark grant is none. A fork must be renamed.
CONTRIBUTING.md has the workflow, the gates, and what a good
PR looks like. CONTRIBUTING-WITH-CLAUDE.md is the
same ground for people working with Claude Code, and .claude/ ships skills and
slash commands (/preflight, /license-of, /file-issue, /fix-doc) that
automate the tedious parts.
Security problems go to colton@futurebuild.ai or a private GitHub Security Advisory — never a public issue. See SECURITY.md.
Everyone participating agrees to the Code of Conduct.