this week I covered Gamma on X. on the Startup Ideas Pod, Justin (@thatsjonsense) said one Gamma deck can go through about 20 models and around 200 calls, and that a vision model checks every slide for things like cropped heads and bad contrast. he also said most of the value still comes from prompts and the harness around the models, not from fine tuning

I don't know how Gamma wires that internally. their careers page lists a TypeScript stack (Next.js, Vercel, Prisma/Postgres, Vercel AI SDK, their own JSX prompting framework), and a Vercel case study talks about HTML going through a conversion layer into a Tiptap schema. none of that is the code below

what I wanted as a solo TypeScript / Node person was a small, boring version of the idea: take a folder of slide images, send each one to a multimodal model, get back structured pass/fail notes, and print a report. no fine tuning. just a harness

Node 20+, one dependency (zod for the JSON shape), run with npx tsx

what "bad slide" means here

I keep the checklist short on purpose. the model is good at noticing obvious layout bugs and bad at inventing product taste. so I ask for:

  • cropped faces or cut-off text at the edges
  • low contrast between text and background
  • overlapping elements
  • empty regions that look unfinished
  • text that is too small to read in a typical deck view

if you ask for "is this a beautiful slide?" you get vibes. if you ask for those five checks you get something you can act on

the response shape

I force JSON with a zod schema so a loose prose answer can't sneak through:

// check.mts import { z } from "zod"; const Finding = z.object({ kind: z.enum([ "cropped", "contrast", "overlap", "empty", "tiny_text", "ok", ]), detail: z.string().min(3).max(240), severity: z.enum(["info", "warn", "fail"]), }); const SlideReport = z.object({ slide: z.string(), pass: z.boolean(), findings: z.array(Finding).min(1).max(8), summary: z.string().max(280), }); type SlideReport = z.infer<typeof SlideReport>; 

pass is the only field the rest of the pipeline should care about. everything else is for me when I'm staring at a bad export at 7am

calling a multimodal chat API

I keep the provider generic. anything that speaks the OpenAI chat-completions shape with image_url content works (OpenAI, Groq vision models, OpenRouter, a local proxy, whatever you already pay for). the key lives in the env, never in the file:

import { readFile } from "node:fs/promises"; import { basename } from "node:path"; const API_URL = process.env.VISION_API_URL!; // e.g. https://api.openai.com/v1/chat/completions const API_KEY = process.env.VISION_API_KEY!; const MODEL = process.env.VISION_MODEL ?? "gpt-4.1-mini"; function toDataUrl(buf: Buffer, file: string) { const ext = file.toLowerCase().endsWith(".png") ? "png" : "jpeg"; return `data:image/${ext};base64,${buf.toString("base64")}`; } const SYSTEM = `You are a slide QA checker for indie decks. Return ONLY JSON matching this shape: { "slide": string, "pass": boolean, "findings": [{ "kind": "cropped"|"contrast"|"overlap"|"empty"|"tiny_text"|"ok", "detail": string, "severity": "info"|"warn"|"fail" }], "summary": string } Mark pass=false if any finding has severity "fail". Be concrete. Point to where on the slide the problem is. Do not invent brand advice.`; async function checkSlide(path: string): Promise<SlideReport> { const buf = await readFile(path); const slide = basename(path); const res = await fetch(API_URL, { method: "POST", headers: { authorization: `Bearer ${API_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ model: MODEL, temperature: 0, response_format: { type: "json_object" }, messages: [ { role: "system", content: SYSTEM }, { role: "user", content: [ { type: "text", text: `Check this slide. filename=${slide}` }, { type: "image_url", image_url: { url: toDataUrl(buf, slide) } }, ], }, ], }), }); if (!res.ok) { throw new Error(`${res.status} ${await res.text()}`); } const raw = (await res.json()) as { choices: { message: { content: string } }[]; }; const parsed = JSON.parse(raw.choices[0].message.content); return SlideReport.parse({ ...parsed, slide }); } 

a few practical notes from running this on my own carousel exports:

  • temperature: 0 cuts a lot of the "maybe this could be nicer" noise
  • response_format: json_object helps, but zod is what actually saves you when the model adds a fifth key or renames severity
  • base64 data urls are fine for a dozen slides. for hundreds, upload to object storage and pass a short-lived public URL instead so you don't blow the request size

walking a folder

I export carousels as 01.png, 02.png, ... so a simple sort is enough. no fancy glob library:

import { readdir } from "node:fs/promises"; import { join } from "node:path"; async function checkDeck(dir: string) { const files = (await readdir(dir)) .filter((f) => /\.(png|jpe?g)$/i.test(f)) .sort() .map((f) => join(dir, f)); const reports: SlideReport[] = []; for (const file of files) { // sequential on purpose: vision calls are the expensive part const report = await checkSlide(file); reports.push(report); const mark = report.pass ? "ok" : "FAIL"; console.log(`${mark} ${report.slide} ${report.summary}`); } const failed = reports.filter((r) => !r.pass); console.log(`\n${failed.length}/${reports.length} slides need a look`); return reports; } await checkDeck(process.argv[2] ?? "./slides"); 

I run it sequential. parallel looks faster until you hit rate limits and half the batch comes back as 429s. for a 10-slide deck the difference is nothing that matters

what a fail looks like

a real report I got back on a draft carousel (one of my own exports, not a Gamma deck) looked roughly like this:

{ "slide": "03.png", "pass": false, "findings": [ { "kind": "contrast", "detail": "light gray body text on a soft mint background in the lower half", "severity": "fail" }, { "kind": "tiny_text", "detail": "footer handles are hard to read at phone width", "severity": "warn" } ], "summary": "fix the body text contrast before shipping" } 

that is exactly the kind of thing I miss when I stare at a design for too long. the model is not "taste". it is a second pair of eyes that never gets tired of checking the same five rules

tying it to what Gamma said publicly

the interesting part of Justin's comment for me as a builder was not "20 models". it was the idea that generation and QA are different steps. you can use a cheap text model to draft, a layout model to place, and a vision model only at the end to reject. the harness decides who runs when

Gamma's public stack (from their careers page) is TypeScript end to end: Next.js on Vercel, Tiptap/ProseMirror for the editor, Prisma + Postgres, ClickHouse, Kafka, and Vercel AI SDK next to their own JSX prompting framework. the Vercel write-up also says AI output goes through a serverless conversion into the editor schema. that is a harness story, not a "we trained a better model" story

so for an indie tool, the copyable lesson is: keep the models swappable, keep the checks structured, and put the product logic in TypeScript around them

what I'd add next

  • write the reports to qa.json and fail a CI step when any pass is false
  • add a second pass that only runs on fails, with a stricter prompt and a stronger model
  • skip slides that did not change since the last run (hash the file, cache the report)
  • for carousels, also check that every slide has the same footer handles. that one is regex on OCR, or another vision question

none of this needs a fine tuned model. it needs a checklist, a schema, and a loop

sources

I post indie app cases and build logs like this on X and Instagram