Skip to content

Vaynerov Technologies

We don't just develop — we conjure every line of code & pixel.

All articlesBuild stories

An AI Architect With Honest Prices

Describe a product in a paragraph and Blueprint draws you a system diagram, a three-phase plan, a team and a price. Everything in that list is generated except the one thing people care about most, and that was the whole design.

Edward AmirainFounder, Vaynerov Technologies
Published 10 min read
On this page
  1. The model proposes; the engine prices
  2. Structured output, and where the bounds actually live
  3. Everything around the model
  4. Demo mode that doesn't lie
  5. Drawing the diagram with no canvas at all
  6. The quote is priced again, on the server

A language model that invents your price is not a feature. It is a liability with a nice font. Somebody reads a number on our site, screenshots it, and turns up two weeks later expecting us to honour it — and they would be right to. The number was on our page.

So when I built Vaynerov Project Blueprint — paste an idea, get an architecture — I wrote one rule down before I wrote a single line of the prompt, and everything else in the feature is downstream of it: the model never prices anything. It only proposes selections, and even those are sanitized before the engine sees them.

What follows is a build story about an AI feature in which the interesting engineering is almost entirely around the model.

8–12
nodes across at least 4 lanes
3
phases, exactly — enforced twice
$150–250
unit rate band, from the calculator
5 / 15 min
generations per IP
The line I refuse to move. Everything on the left is a proposal; everything on the right is arithmetic that /pricing would produce for the same selections.

The model proposes; the engine prices

The calculator behind /pricing is older than Blueprint — it went in with the site itself and grew a rules table in June. It is about as unglamorous as software gets: every platform, feature, AI capability, design level and integration tier carries a base unit count; those sum; the sum is multiplied by product complexity, then a QA multiplier, then a launch multiplier; the adjusted total meets a unit rate band and becomes a range in dollars.

PlatformBase unitsBaseline weeks
Web app508
iOS406
Android406
Backend / API305
Admin panel254

Five of the engine's default rules. Features price the same way — auth 8 units, payments 15, realtime 10, an admin CMS 20; retrieval-augmented AI 25, agents 30.

The timeline is the same kind of arithmetic. Platforms are assumed to run in parallel, so they contribute the maximum of their baseline weeks rather than the sum; everything that is not a platform converts at fifteen units per week; then two weeks of buffer, because there are always two weeks.

Blueprint calls exactly that engine, with exactly those rules, loaded from the same calculator_rules table the pricing page reads — and if the table is missing or unreachable, both degrade to the same hard-coded defaults rather than to a guess. What the model contributes is a selection: a platform, a product type, a list of features. Then sanitizeSelections runs before pricing does. Unknown identifiers are dropped rather than mapped charitably. Required single-choice fields that came back empty are filled from defaults.

The part I am quietly pleased with: the JSON schema enums the model generates against, the option catalog printed in the prompt, and the sanitizer's allowlist are all derived from the same rules object. Add a feature to the calculator and it appears in all three. They can never drift, because there is nothing to keep in sync.

Structured output, and where the bounds actually live

Blueprint uses Claude Opus 4.8 (claude-opus-4-8) with structured output — a JSON schema attached to the request's output configuration, rather than the older habit of defining a fake tool and reading its arguments. The response is JSON because the decoder was constrained to produce JSON, not because we asked nicely and then wrote a parser with a try around it.

That constraint has a shape, though, and it is not the shape of a validation library. A schema that steers generation has to stay simple: our hand-written one sets additionalProperties: false and lists every property as required, expresses optional fields as anyOf: [{ type: "string" }, { type: "null" }], and carries no minItems, maxItems, minimum or maximum anywhere.

{
  "type": "object",
  "additionalProperties": false,
  "required": ["id", "label", "lane", "tech", "note"],
  "properties": {
    "id": { "type": "string" },
    "label": { "type": "string" },
    "lane": { "enum": ["client", "edge", "services", "data", "infra"] },
    "tech": { "type": "array", "items": { "type": "string" } },
    "note": { "anyOf": [{ "type": "string" }, { "type": "null" }] }
  }
}

One node of the graph schema. Optionality is anyOf-with-null because the grammar has no notion of an absent required key; counts and lengths are deliberately absent.

The tight bounds live one layer down, in zod, after the parse: three to fourteen nodes, at most thirty-two edges, a phases array of length exactly three, three to six team roles, two to four risks, a week count between one and forty, and an idea between ten and a thousand characters. So the schema guarantees shape and zod guarantees sanity, and the prompt asks for the aesthetic target in between — eight to twelve nodes spread across at least four lanes with at least one piece of infrastructure, eight to sixteen edges, phases of two to ten weeks carrying two to four deliverables each. The single few-shot example in the prompt is a deliberately small six-node system, because an example is a gravity well and I would rather it pulled downward.

The system prompt also carries our house style ban list — the two marketing words that are never allowed on this site are named and forbidden to the model. It has never been the hard part of the prompt, but it is the part I check first.

Everything around the model

The request is cache-shaped on purpose. Prompt caching is a prefix match, which means it survives only if the leading bytes are identical every single time — so the system prompt, the option catalog and the few-shot are byte-stable and ordered deterministically, and everything volatile (the visitor's locale, the visitor's idea) rides on the user turn where it belongs. Sorting the option catalog non-deterministically would have quietly cost us the cache on every request, and nothing would have looked broken.

The visitor's idea is wrapped in <idea> tags with an instruction that its contents are data, not instructions. This is not a solved problem and I will not pretend otherwise. It is a meaningful reduction in the easy attacks, on a surface where the worst outcome is a silly diagram, and where the price — the thing worth attacking — is not reachable from the text at all.

The strongest defence against prompt injection is not a clever prompt. It is not routing anything valuable through the prompt.

On the response side, we check stop_reason before we look at content. A refusal becomes a refused code; hitting the 16,000-token ceiling becomes truncated; every internal error class maps to a small client-safe code, and raw error text never leaves the server — an upstream provider message is our infrastructure detail, not the visitor's reading material. Everything is streamed, but only to keep the connection alive under a two-minute maximum duration; we take the final message and validate it whole.

Then sanitizeBlueprint, which is lenient by design, because the goal is a renderable graph and not a perfect one. It trims past the fourteen-node ceiling, dedupes ids, drops self-loops and edges pointing at nodes that do not exist, prunes phase node references that no longer resolve, and appends orphaned nodes to a declared phase so nothing floats. It returns null only when the result is structurally unusable. There is no retry loop in v1, which is a limitation I chose rather than one I discovered: a retry would double the latency of the worst case to rescue an output we could not describe in advance.

The transport is server-sent events, and the route always answers **HTTP 200 with text/event-stream** — failures arrive as an error event inside the stream, so the client consumes exactly one protocol either way and has no second code path to rot. Four status frames fire at real milestones rather than on a timer: queued, drafting, validating, pricing. The client's SSE reader is hand-rolled over fetch and a ReadableStream, splitting on blank lines, because the browser's EventSource cannot issue a POST.

Demo mode that doesn't lie

When no API key is configured, Blueprint still works. The server reports generation as unavailable, the client never calls the route at all, and four hand-authored reference blueprints — a SaaS product, a marketplace, a support tool, an IoT platform — are keyword-matched to whatever you typed. Progress is staged so the page still feels like it is thinking.

Here is the part that matters: the demo prices are real. They are not sampled from the reference file. They run through calculateEstimate with the same default rules, so the numbers a visitor sees with the model switched off are the numbers /pricing would produce for the same configuration. A demo that quotes fake money is a demo that teaches visitors our money is fake.

The abuse controls are cheap and slightly mean. There is a honeypot field parked far off the left edge of the viewport; a bot that fills it gets a completely convincing success — a canned blueprint, correct pricing, an animated diagram — without our spending a single token, and if it goes on to request a quote, it receives a valid-looking token that 404s. Real traffic is limited to five generations per fifteen minutes per address, counted by an atomic fixed-window function in Postgres, with an in-memory map as the fallback when the database is unreachable.

Drawing the diagram with no canvas at all

Blueprint's output is a system diagram, and after a long season of WebGL I enjoyed building this one in plain SVG. No canvas, no three.js, no measurement pass. The layout is a pure module with no React and no DOM access: five fixed lanes — client, edge, services, data, infrastructure — nodes 148 by 54 units, columns 176 wide, a minimum of four columns.

Those three constants are a proof, not a preference. Column width exceeds node width, nodes are sorted by phase and then original order and spread evenly across the available columns, so two nodes cannot overlap at any node count up to the ceiling — there is no collision pass, because collisions are impossible. Every layout bug I have ever shipped was in a collision pass.

Edges are cubic Béziers. Same-lane connections arc over their lane by 85% of a node's height so they never run through the boxes between them; cross-lane connections pull by at least 26 units, scaled to the vertical distance travelled. The three kinds of edge are distinguished by dash pattern rather than by colour — synchronous solid, asynchronous long-dashed, data flow finely dotted — with a legend, because colour alone is not information everyone receives.

Five lanes, dash-coded edges, a hard no-overlap guarantee from the column arithmetic. Lane colour is decoration; the dash pattern is the data.

A few smaller decisions I would make again. Opacity carries meaning: a node filtered out by phase sits at 0.14, a non-neighbour of the hovered node at 0.22, a node outside the hovered phase at 0.30 — three tiers of not right now. Technology chips are width-budgeted arithmetically from character count, capped at two chips and truncated at thirteen characters, because SVG has no reflow and text that overflows a box in SVG simply keeps going. Nodes are real buttons for the keyboard, with Enter and Space, and the pulses travelling along the edges are disabled outright when the visitor has asked for reduced motion — the progress bar likewise swaps its indeterminate scan for a static proportional fill.

The quote is priced again, on the server

When a visitor likes what they see and asks for a real quote, the browser sends the selections. It also sends the numbers, and the server ignores them completely. Honeypot, then schema validation, then the rate limit, then sanitize the selections again, then fetch the rules again, then run the estimate again — server-side, from scratch — and only that result is written to the quote record. The comment above it is four words long and does the job: never trust client numbers.

Which brings the feature back to where it started. The diagram Blueprint hands you is a proposal — a sketch of one reasonable shape your product could take, and a sketch is allowed to be argued with. That is what the first conversation with a studio is for. The price is not a sketch. It came out of the same engine, from the same rules table, as every other number on this site, and it will still be that number when you screenshot it.