How I Built Velocity — An AI Platform That Turns Plain English Into Production-Grade n8n Workflows A developer built Velocity, an AI platform that converts plain-English descriptions into production-grade n8n workflow JSON. The system uses a Next.js 16 frontend with Gemini API and Supabase backend, treating the system prompt as a strict schema contract to avoid common import errors. Velocity generates complete, importable workflows with real triggers, configured parameters, and error notifications. You describe the automation. Velocity ships the workflow — importable, deployable, running in your n8n instance one click later. Every automation builder has been there. You know exactly what you want: "when a form is submitted, enrich the lead, drop it in a sheet, and ping Slack." Then you spend the next two hours in the n8n canvas hunting for the right node, guessing parameter names, wiring connections, and debugging Could not find property option errors on import. The knowledge to build that workflow exists. It's just locked behind hundreds of node types, typeVersion quirks, and expression syntax. So I built Velocity — an AI platform that takes a plain-English description and generates a complete, valid, importable n8n workflow JSON. Not a 4-node demo. A production-grade pipeline with real triggers, configured parameters, error notifications, and sticky-note documentation — the depth of an actual published n8n template. Velocity is a full-stack AI automation copilot. The core loop: Tech stack: ┌─────────────────────────────┐ │ Next.js 16 App │ │ │ Browser ──────────────▶│ /api/chat ─────────┐ │ │ │ /api/generate-workflow ─┐ │ ┌──────────────┐ │ Supabase JWT │ /api/explain-error ──┐ │ │ │ Gemini │ │ Authorization │ /api/analyze-workflow│ │ ├─────▶│ REST API │ │ header │ /api/plan-workflow ──┘ │ │ │ + fallback │ │ │ │ │ │ models │ │ │ /api/deploy-to-n8n ─────┼──┼──┐ └──────────────┘ │ │ /api/templates ─────────┼──┼──┼─▶ api.n8n.io │ │ /api/conversations ──┐ │ │ │ │ └───────────────────────┼──┼──┼──┼──┘ │ │ │ │ │ │ ┌─────────────────▼──▼──┤ └─▶ Your n8n │ │ Supabase Postgres │ instance └─────────────────────────▶│ RLS: auth.uid │ create + └───────────┬───────────┘ activate │ ┌───────────▼───────────┐ │ Redis │ │ · recent-turns cache │ │ · rate-limit counters│ └───────────────────────┘ Every AI route follows the same pipeline: verify JWT → rate limit → build prompt → call Gemini with failover → repair/parse JSON → persist → respond. Postgres is always the source of truth; Redis is always a cache that fails open. The single hardest problem wasn't code — it was getting the model to emit workflow JSON that n8n will actually import . n8n is unforgiving: a wrong typeVersion loads a different parameter schema, a hallucinated dropdown value throws Could not find property option , and referencing a workflow that doesn't exist throws Could not find workflow . The fix was treating the system prompt like a schema contract, not a vibe. A few of the rules that came directly from watching real imports fail: IMPORT SAFETY — these prevent the two most common load errors: - typeVersion: use 1 for simple core nodes unless you specifically need a newer version's fields. A wrong typeVersion loads a different parameter schema and breaks with "Could not find property option". - Do NOT emit resource-locator objects {" rl":true,"mode":...,"value":...} . Their mode/value pairs are the 1 cause of "Could not find property option". - NEVER reference another workflow you don't have a real id for. - "connections" keys are the SOURCE node's NAME exact, case-sensitive — never its id. Each of those lines exists because a generated workflow broke without it. The prompt also encodes verified parameter shapes for the most common nodes HTTP Request, Set, If, Webhook, Schedule Trigger , because "parameters": {} produces workflows that import but do nothing: - HTTP Request POST : {"method":"POST","url":"...","authentication":"none", "sendBody":true,"body":{"contentType":"json","content":{...}}} GOTCHA: POST/PUT/PATCH MUST include "sendBody":true. And the killer rule for accuracy: when a service has no dedicated n8n node, use a fully-configured HTTP Request node against its REST API instead of guessing. A configured httpRequest beats an empty branded stub every time. Velocity has two AI surfaces that both emit n8n JSON: the conversational copilot /api/chat and the structured one-shot generator /api/generate-workflow . Early on they had separate prompts, and they drifted — a rule fixed in one route would still break in the other. The fix was boring and effective: the n8n contract lives in exactly one file, and both routes compose their system prompts from it. js // n8nPrompt.ts — the single source of truth export const N8N WORKFLOW SHAPE = An n8n workflow is a JSON object that imports cleanly: {...} ; export const N8N RULES = RULES — follow every one: ... ; // Conversational copilot used by /api/chat export const CHAT SYSTEM = You are Velocity, an AI copilot... ${N8N WORKFLOW SHAPE} ${N8N RULES} ... ; // Structured one-shot used by /api/generate-workflow export const GENERATE SYSTEM = You are Velocity, an expert at building n8n workflows... ${N8N WORKFLOW SHAPE} ${N8N RULES} ; If you have two prompts encoding the same output schema, they will drift apart. Shared constants are the cheapest insurance you'll ever buy. I run this on Gemini's free tier, and the preview model I use gemini-3-flash-preview has a 20 requests per day allowance. That's not a rate limit, it's a countdown timer. Rather than let the app die at request 21, the Gemini wrapper detects quota-exhausted 429s and transparently fails over to stable models that still have quota: js export const GEMINI MODEL = "gemini-3-flash-preview"; const FALLBACK MODELS = "gemini-2.5-flash", "gemini-2.0-flash" ; // Signals the primary/fallback loop to try the next model. class QuotaExhaustedError extends Error {} async function generate contents, maxTokens, temperature, jsonMode = false { const models = GEMINI MODEL, ...FALLBACK MODELS ; let lastQuota: QuotaExhaustedError | null = null; for const model of models { try { return await generateWithModel model, contents, maxTokens, temperature, jsonMode ; } catch err { // Only a spent quota triggers failover; every other error is terminal. if err instanceof QuotaExhaustedError { lastQuota = err; continue; } throw err; } } throw lastQuota ?? new Error QUOTA MESSAGE ; } The subtle part is distinguishing the two kinds of 429. A regular rate-limit 429 recovers if you back off; a quota 429 will not clear until tomorrow, so retrying is pure waste. The tell is in the response body: // A 429 whose body mentions quota/billing or "limit: 0" means the key's // free-tier allowance is exhausted — retrying won't help. function isQuotaExhausted body: string : boolean { return /\blimit:\s 0\b|exceeded your current quota|billing/i.test body ; } Transient errors 500/502/503/504 and recoverable 429s get exponential backoff with jitter — and the wrapper honors the API's own Retry-After header and Gemini's RetryInfo body when they suggest a delay. If the suggested delay is longer than the request budget, it gives up early with a human-readable message instead of hanging. I also skipped the SDK entirely. The wrapper is a raw fetch against the Generative Language REST API — ~230 lines including all the retry/failover logic. When your error handling is your reliability story, owning the HTTP layer beats fighting an SDK's opinions. Even with responseMimeType: "application/json" forced, LLM output is only almost JSON often enough to hurt: markdown fences around the object, a stray sentence before it, trailing commas, literal newlines inside string values, and — the worst one — output truncated mid-object at the token limit. A naive JSON.parse raw.slice raw.indexOf "{" , raw.lastIndexOf "}" + 1 fails on all of those. So I wrote parseModelJson , a repair parser that extracts the first balanced JSON value with a proper scanner string-aware, escape-aware , fixes control characters, and — if the input was truncated — closes the open string and any open brackets so the result still parses: // Reached the end with brackets still open → truncated. Best-effort close. if inString out += '"'; out = removeTrailingCommas out.replace /,\s $/, "" ; while stack.length out += stack.pop ; return out; Then it tries candidates from cleanest to rawest, each with a trailing-comma-stripped retry: js for const candidate of extracted, stripped, raw { if candidate continue; const direct = tryParse