Prompt Engineering as an API Contract
Jul 2026 · 8 min read
Agents filling out a property listing form on the platform I architected lose real time writing a good description — and offering an AI-generated first draft is the obvious feature to build on top of that. Obvious GenAI features have an obvious failure mode, though: generic, over-enthusiastic, occasionally just-wrong marketing copy, in a domain where trust in the listing is the entire product. The interesting engineering work here wasn't calling a model. It was designing everything around that call so the output stays trustworthy by construction.
A model-agnostic gateway, not a vendor SDK
The description generator doesn't call OpenAI or Anthropic directly — it goes through OpenRouter, with the actual model selected by an environment variable:
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const DEFAULT_MODEL = "openai/gpt-4o-mini";
const model = process.env.OPENROUTER_MODEL || DEFAULT_MODEL;That decouples "which model are we using this month" from "how does the feature work." Model pricing and quality trade-offs shift fast enough that binding a feature directly to one vendor's SDK is a real, current constraint, not a hypothetical one — swapping models here is a config change, not a rewrite of the feature.
What enters the prompt is an allowlist, not "whatever's in the form"
Before any form data reaches the model, it's normalized: only the fields that matter to a listing description are picked out, everything is coerced to a consistent string representation, and empty or falsy values are dropped entirely rather than passed through as empty strings or nulls.
const pick = (value: unknown): string | undefined => {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value === "boolean") return value ? "Yes" : "No";
return undefined; // never pass empty/falsy noise into the prompt
};This isn't tidiness for its own sake. Every field that reaches the prompt is either useful signal or an invitation for the model to "helpfully" fill a gap with something invented — and every field costs tokens whether or not it adds anything. Deciding exactly what's allowed into the prompt, explicitly, is the same discipline as an allowlist-based API response schema: define what's in, and everything else is out by default, rather than forwarding whatever happens to be sitting in the object.
The system prompt is where the actual product decision lives
The model choice matters far less here than the constraints in the system prompt:
"You are an expert real-estate listing writer for India. Write concise, natural,
trust-building property descriptions without hype, emojis, or fake claims.
Avoid repeating obvious fields verbatim. Keep it under 120 words."In a domain where a listing description is implicitly a trust signal, the real failure mode isn't "the model writes badly" — it's "the model writes exactly like every over-enthusiastic listing already on the internet," which actively works against the thing a property platform depends on. That constraint is a product decision, not a model-capability question, and it belongs in the prompt where it can be reviewed and changed without touching application code.
Temperature is tuned the same deliberate way — 0.5, not the default. Too low and every generated description reads identically regardless of the property; too high and the model starts drifting into invented specifics or overwrought language for a domain that needs the opposite. Landing in the middle is a considered style choice, not a value left untouched from a framework default.
Fail cheap, before the expensive call
Configuration and input validation — is the API key actually set, is the request body actually a well-formed object — happen before the network call to the model, not after. A model call costs real latency and real money compared to a local check; cheap validation belongs strictly in front of it, not discovered after the fact by the model failing on bad input it should never have received.
A 200 response isn't the same as a useful one
The response handling explicitly checks that a description actually came back before returning success to the caller:
const description = completionData?.choices?.[0]?.message?.content?.trim();
if (!description) {
return NextResponse.json({ error: "No description generated from AI response." }, { status: 502 });
}A model call can return a successful HTTP status and still not contain what was actually asked for. Code that only checks the status code will happily ship an empty description straight through to a user, and the failure won't surface until someone notices a blank field in production. Treating "did we get usable content back" as a separate check from "did the request succeed" is what catches that case before a user ever sees it.
Two ends of the same discipline
This pairs with the conversational property search built on the same platform, as the two ends of applying GenAI narrowly rather than broadly. One is on the input side — turning a user's free-text intent into ranked results over real listings, never generated ones. This one is on the output side — turning structured, allowlisted data into constrained, reviewable text. Neither treats the model as a black box pointed at a problem. Both treat the boundary around the call — what's allowed in, what's required to come out, what happens when it doesn't — as the actual engineering work, with the model call itself as the smallest piece of the system.