REST API
Everything the app does goes through the same JSON API, and a key lets a script do it too. Create one under avatar menu → Connections & API keys and send it as Authorization: Bearer hvb_…. Keys act as you, with your tier's entitlements; they cannot reach admin routes or approve connectors.
Solve
POST /api/solve — body {graph, boundary, reversed?}, reply the solution (per-component states and duties, circuits, totals, warnings) or 422 {error}. A graph names a catalog item by catalogRef and the server solves it with the store's own data for that item; a graph may nest systems (subsystems). Costs one token per ten components, counted on the graph as it is solved — nested systems flattened — and charged once the model builds: a request refused before that (a wiring fault, a missing boundary, a refrigerant nobody serves) costs nothing, and a solve that runs and does not converge stays charged, because the work was done. No plan caps the size of a design, but the engine has a ceiling: a graph of more than 500 components — nested systems flattened, and the junctions the wiring implies counted — is refused before anything is analysed.
What a solution says of itself. A circuit's condensing temperature is held at or above its head-pressure floor — the compressor's minCondensingC, its envelope's lower edge at that evaporating temperature, or a lift of 5 K — and a point held there says so in warnings; at or above the fluid's critical temperature the solve is refused, naming the compressor and the fluid. outOfRange lists the machines whose point lies outside what they declare ({componentId, limit}), so a client can tell a converged answer from a valid one without reading the warnings. A reversed circuit's cop is its heat over its power. An impossible input is refused by name rather than answered: a flow that is not positive, a component no air reaches, a pair temperature a boundary needs, one exchanger in two circuits' roles, a chain with no temperature at its head, a coil asked for more than its air can give, a reported state off the chart. A solve that cannot go on says why: notConverged (its iterations ran out) or stalled (no step could be taken).
What an answer cost. Every answer that took tokens — a solve, a rating, a run's job id, a report — carries two headers: x-tokens-charged, the tokens it cost, and x-tokens-status, the balance it left, as the tokens object of GET /api/me. A refusal for want of tokens is 403 with code: "tokens" and that object in the body.
{
"graph": {
"components": [
{"id": "evap", "kind": "dxEvaporator", "spec": {"faceHeightMm": 1473, "faceLengthMm": 1100, "rows": 3, "fpi": 13.4, "circuits": 12, "airFlowM3s": 4, "pattern": {"type": "threeEighthsIn"}}},
{"id": "comp", "kind": "compressor", "spec": {"model": "…", "refrigerant": "R410A", "capacityCoefficients": [10 numbers], "powerCoefficients": [10 numbers]}},
{"id": "cond", "kind": "condenser", "position": [2.6, 0.8, -3.2], "rotation": 1, "spec": {"…": "position and rotation are cosmetic — the solver reads neither"}},
{"id": "cond", "kind": "condenser", "spec": {"…": "as the evaporator"}}
],
"connections": [
{"from": {"componentId": "evap", "port": "refOut"}, "to": {"componentId": "comp", "port": "suction"}},
{"from": {"componentId": "comp", "port": "discharge"}, "to": {"componentId": "cond", "port": "refIn"}},
{"from": {"componentId": "cond", "port": "refOut"}, "to": {"componentId": "evap", "port": "refIn"}}
]
},
"boundary": {"altitudeM": 0, "airInlets": {"evap": {"db": 24, "wb": 17}, "cond": {"db": 35, "wb": 24}}}
}
mode names the stage the design point is solved at: all (the default — every component runs), cooling (heaters off, every circuit forward) or heating (heaters on, reversible circuits reversed, cooling-only compressors off — a circuit on an evaporative condenser is one); a heater carries a winter target and fires flat out on a summer point under all. Boundary keys are a component id (its primary air inlet) or id:airInSecondary. An entering state has to be air: a wet bulb above its dry bulb, or below the wet bulb that air with no water in it has at that dry bulb, is refused with 422, naming the inlet and the limit it crossed; a wet bulb up to 0.05 K above the dry bulb is read as saturated — two bulbs that show the same figure. waterInlets map an id to a supply temperature; refFeeds / refLoads carry a refrigerant pair's half.
A pair heats as well as cools. A machine cut along its refrigerant lines is still one machine with one reversing valve, so on a heating hour both halves turn round together: the indoor coil condenses into its room and the outdoor coil evaporates out of the weather, paying the same frost derate a whole heat pump pays. In a building run the coupling does this for you — the indoor block's own hour decides which way round the pair goes, and a cooling-only outdoor unit (reversible: false) delivers nothing and says so rather than pretending. Written by hand, a heating half sets heating: true and names the other end of the circuit: refFeeds then takes teC, the far unit's evaporating temperature, and refLoads takes tcC, the far coil's condensing temperature. The duty field keeps its name and carries the heat.
Rate a machine (IEER)
POST /api/rate/ieer — body {graph, boundary?, supplyComponentId?, mzvav?}, reply the rating or 422 {error}. Where /api/solve answers one condition, this answers the question a datasheet answers: it solves the machine at the four load points AHRI 340/360-2022 Table 9 defines and weights the four EERs by its Eq. 3.
| 100 % | entering condenser air 35.0 °C | weight 0.020 |
| 75 % | 27.5 °C | 0.617 |
| 50 % | 20.0 °C | 0.238 |
| 25 % | 18.3 °C | 0.125 |
Return air is 26.7 °C dry bulb and 19.4 °C wet bulb at every point. The reply carries ieer in W/W and ieerBtuWh in Btu/(W·h) — the second is what a maker quotes, so it is the one to compare a published figure against — fullLoadEer, and a points array with each point's netCapacityKw, powerKw, eer, loadFactor, degradation, capacityScale, airflowScale and leavingDbC.
Net capacity is the coil's duty less the heat the indoor fans put into the air; the input power includes them. Where the machine cannot modulate down to a point it reaches it by cycling, with the standard's degradation factor. Heaters and humidifiers are off at every point, because this is a cooling rating: a rooftop with a furnace would otherwise fire it against its winter target while the coil cools.
Set mzvav: true for a multi-zone VAV unit, whose indoor airflow comes down with the load (§6.1.3.4.5). Leaving it false on such a machine makes it pay full fan power at quarter load: the published VAV rooftop reads 13.9 Btu/(W·h) that way against 16.9 rated properly. Air-cooled condensers only for now; Table 9's water-cooled and evaporative columns are not implemented. Costs one token per ten components, the same as a solve.
Catalog and weather
GET /api/catalog/index | Every kind in full except compressors, which arrive slim (identity, facets, one rated kW), and except the published curves — a fan's or pump's pressure and power curve, a core's or pad's effectiveness — which the per-item route below serves. Strong ETag. |
GET /api/catalog/items/{id} | One part of any kind as {kind, item}. An account gets the item whole, with its curves; a visitor gets the index's own row for it — the same part without them. A design loses nothing either way: every solve resolves a catalogRef against the store. Rate-limited like the compressor routes. |
GET /api/catalog/compressors/{id} | One compressor with its coefficient sets, steps and envelope. Signed in, and free: any account, key or connector token. A machine's rating point is public — on its catalog page, in the index's ratedKw, and at any single condition through the lookup; its map is what an account reads. |
GET /api/catalog/compressors/pick | The picker's question: filters (q, ref, brand, app, speed, hz, tech, region), a capacity window (kwmin, kwmax), an order (sort: cap-desc, cap-asc, cop, name) and a condition to read at (te, tc). The answer is the matches read at that condition and each filter's counts — one page of 60 rows a call, whatever limit asks for. A visitor reads at the rating point (7.2 / 54.4 °C, which is what leaving te and tc out means); naming another condition without an account answers 401 with code: "account". Any account, key or connector token reads at any condition. The route has a rate limit of its own. |
GET /api/catalog | The whole document (gzipped). Signed in, and metered: this route and GET /api/catalog/compressors are the only ones that hand over every coefficient set at once, and each answer costs 25 tokens from the caller's budget. One machine at a time costs nothing, and /api/catalog/index — every listing with its rating point, no coefficients — is open to anyone. |
GET /api/catalog/refrigerants | The fluids the property backend serves. |
GET /api/weather, /api/weather/stations | Your weather library — the years you imported or generated (mine: true, listed for your account or workspace alone) and the shared city years — and the stations a synthetic year can be generated for. Every weather route but the station list takes an account. |
GET /api/weather/cities, POST /api/weather/tmy | The TMY cities, and one city's PVGIS year by {cityId}: fetched once, then shared by every account. A plan that cannot simulate may fetch the cities the example buildings run on, and no other. |
POST /api/weather/generate, /api/weather/import | A labelled synthetic year; an hourly CSV, {name, altitudeM?, mapping, csv} with mapping naming your columns — db and one of wb / rh, optionally timestamp and ghi. Who may add one: an account on a plan that can simulate, and not a workspace viewer; an account keeps up to 25 of its own (422, code: "datasetCap" — deleting one makes room). What an import must be: hourly — with a timestamp column (year first, YYYY-MM-DD HH:MM) the step is checked and any other is refused, without one the rows are taken as consecutive hours from 1 January; up to 5 years of rows; every number finite; dry bulb and wet bulb between −90 and 60 °C. The file may be cut with commas, semicolons or tabs, and a decimal comma is read where the comma is not the delimiter. A row with a cell that cannot be read is left out — and keeps its place in the calendar, so the hours after it do not move up — and the reply's importReport says so: {rowsRead, rowsSkipped, skipped: [{row, column, value}]}, naming the first 20. |
GET /api/weather/{id}/preview | A dataset's listing row and its first hours (?limit=, a month at most). |
DELETE /api/weather/{id} | Delete a dataset of your own. A shared one — a fetched city year — is every account's to use and answers 403 with code: "sharedDataset". |
Simulation
POST /api/simulate | A system against a weather year, with or without a control; returns a job id. GET /api/simulate/{id} polls, /events streams, /results reads. |
POST /api/simulate/building | Exactly one of buildingId (stored) or building (inline {name, zones, systems}); a block carries a systemId or an inline graph. With an inline building, draftOf names the saved building it is a draft of — the app runs what is on screen, so a saved building's run arrives inline — and the run is filed under that building's runs; an id the caller cannot read is dropped, and the run is filed with the ad-hoc ones. Same job endpoints. |
GET /api/simulate | Your runs, each as its status row: id, status, name, kind, done/total, the weather year's name as it was when the run was made (datasetName), the hours it covers (hours) and, once finished, its unmet hours (unmetHours). |
DELETE /api/simulate/{id} | A finished run: deleted with its results. A run in progress: cancelled — it stops at its next hour, and the tokens for the hours it did not compute come back: {deleted, cancelled: true, refunded}. |
POST /api/report[?format=pdf|schedule] | The spec report as HTML, as a PDF, or its equipment schedule as CSV: {graph, boundary, name?, annual?, datasetName?, designPoints?}. A report is a solve, and costs what its solves cost: one for the design point, and one more for each extra design point it tabulates — designPoints, at most 12 — each at one token per ten components. A point whose model does not build is a row that says so, and free; the schedule solves the design point alone. Refused without the tokens for the whole of it. |
POST /api/report/building, POST /api/report/chart | The building report for a finished job ({jobId}); the psychrometric chart tab's own drawing ({svg, source?, pressureKpa?, legend?}) printed in the report shell. Neither solves anything, and neither costs tokens. |
A simulated year costs ten times what one solve of the same system costs; a building run charges each block for its own system, and is tried over its first hours before its year is charged. Two runs may be in progress on an account at once, and a server takes a few more in all: past either a run is refused with 429 and accountRuns or serverRuns, and a Retry-After. A run that fails for the server's own reasons reads status: "failed" with code: "internal" (its worker failed) or "interrupted" (the server stopped while it ran), and what it was charged comes back (refunded).
Account and content
GET /api/me | Identity, limits, capability flags, token balance. |
/api/systems, /api/systems/{id} | Saved systems: list, create, read, update, delete. Owner-scoped; a foreign id reads as 404. An update holds to the revision it read when it says which: ifUpdatedAt, the updatedAt the record was read at — saved elsewhere since, the update is refused (409 stale, with the record's updatedAt and updatedBy) and nothing is written; without it, the update overwrites. An update answers {id, updatedAt}. |
/api/components, /api/components/{id} | Your component library, the same way. |
/api/buildings, /api/buildings/{id} | Saved buildings, the same way. A save keeps a building in progress — a room no system serves yet, a block with no machine chosen — as long as what it holds makes sense; a run refuses one that is not whole, and names what is missing. |
GET /api/workspace, DELETE /api/workspace | The caller's workspace and what it holds — {id, name, holds}, with holds counting its systems, components, buildings, runs and weather datasets and the tokens it bought; any member may ask. DELETE is the owner's: it dissolves the workspace and hands everything it holds to the owner's own account, answering what went as moved. |
POST /api/workspace/invite | A new invite link, for the owner or a manager: {inviteToken}. The link it replaces stops working, and a link is replaced by itself whenever a member is removed or leaves. |
POST /api/rfq/match, /api/rfq/fanout (+ -building) | Preview the suppliers a design matches — kinds, duty, refrigerants, regions and, when the design has one, its category — then send them the request. |
POST /api/rfq/match-accessories, /api/rfq/fanout-accessories | Preview the suppliers of a system’s typed-in accessories — by product category and region, nothing solved — then send each one only its own lines. |
/api/vendor/* | The Company page: profile, listings, offerings, leads, capabilities. |
GET /api/presets, /api/presets/{id} | The published supplier systems, the examples among them. Public, like their pages. The list is a shelf: a row carries the offering's marketing fields and its supplier's companyName, and where its pictures are served — logoUrl, imageUrl, each absent when there is no picture — never a picture inline and never the design. /api/presets/{id} returns one offering whole: its graph and boundary, and its pictures as stored. |
GET /api/presets/{id}/solution | A published system's design point, public and uncharged, in the shape POST /api/solve answers: the point the system's public page shows, solved once per version of the offering and kept. 404 for an id that is not a published system, 422 with the solver's reason for one that does not solve, 429 busy while the point is still being solved. |
POST /api/layout | The graph laid out in rows the way the examples are: {graph} → {graph, stacked}. A graph of more than 500 components is refused (422). |
GET /api/health | {status, properties, storage, auth} — the property backend (coolprop or fits), the storage (postgres or files) and the authentication (clerk or dev) serving. Public; answers whatever the solves are doing. |
/api/mcp | The MCP endpoint (Streamable HTTP), bearer key or OAuth token. |
Errors
A refused request is 4xx {error} with a sentence naming the reason. An entitlement refusal is 403 with a machine-readable code: simulation, saving, maxSaved, buildings, maxBuildings, tokens, assistant, assistantKey, vendorTier, maxKeys, exampleRuns, fanoutRfq. tokens is also what a solve, a run or a report answers when the balance does not cover it. Four more are not about a plan: tokenStore on a 503 (the balance could not be read just now; nothing was charged, and the request can be sent again), account on a 401 (the compressor picker asked for a condition of your own without an account), datasetCap and libraryFull on a 422 (the weather library would not keep another dataset — the account's own cap, or the service's), and sharedDataset on a 403 (a shared weather year is not yours to delete). The server's own state: storage on a 503, on any route (the server could not reach its storage just now; nothing was read or changed by the request, and it can be sent again), busy on a 429 (no solve slot came free within a few seconds; retryAfterS and a Retry-After header say when to come back, and nothing was charged), accountRuns and serverRuns on a 429 (see Simulation), solveBudget on a 503 (a solve outran its budget and was left to finish; its charge came back, as refunded) and internal on a 500 (something failed on the server's side; refunded when a charge came back). A failed run's own code is internal or interrupted (the server stopped while it ran). A workspace's: workspace (workspaces are Business and up), role (a viewer cannot change the workspace's work), seats (no free seat), noSubscription (a seat change needs a subscription to attach to). The assistant's assistantDaily (its daily message ceiling). A design's: stale on a 409 (an update that names the revision it read, of a record saved elsewhere since), duplicateComponentId on a 422 (a system is saved with one component per id), accessory on a 422 (an accessory the design names is not its category's: an unknown or modelled category, an attribute the category does not state, a quantity or a text past its bounds; or an accessory quote request names one the system does not hold, a listed one — its quote goes to its supplier — or none to quote) and reuse (a published system its author does not let others nest). The Company page's: duplicateListing (an identical listing exists), listingCap (too many listings of one kind), image (an image that is not a PNG, JPEG, WebP or GIF data URI) and productUrl (a product link that is not http or https).