The Prompt I Used to Build Hook Lab in Emergent (20 Scored Instagram Hooks, Biased Toward What Already Worked)

Published August 2026 · avivashishta.com

Overview

I make reels about coding. The reel itself is rarely the problem — the first line is. You get roughly two seconds before the thumb moves, and writing twenty candidate first lines by hand at 1am is exactly the sort of work that should be a tool.

So: type one line about what the reel is about, pick a format and a tone, get twenty hooks back — each tagged with the hook format it is using, scored out of 100 with a reason, and flagged red if it is too long to survive the first two seconds. Star the good ones into a private library. Paste in your past reels and their view counts, and the app works out which formats actually perform on your account and biases future generations toward them. Pick a hook, and it writes the full caption. Log the views the published hook got, and the scoring gets better.

It is a full-stack app — email login, a database per user, real AI calls — so this one went into Emergent rather than a single HTML file. The prompt below is the whole build, pasted at once.

What it produces: a dark, developer-tool-looking web app with five sections — Generate, Library, My Winners, Caption, and Tracker — plus a dashboard strip. Twenty tagged and scored hooks per run, sortable and one-click copyable, a format-performance chart built from your own view counts, and a caption builder with per-section regeneration and a live character counter.

Stack: whatever Emergent picks — React front end, Python API, Mongo, email auth, one LLM key. The prompt deliberately does not fight the platform on stack; it fights it on the four things it would otherwise get wrong.

The prompt

Paste the whole thing at once.

Build a full-stack web app called "Hook Lab" — a tool for writing and testing viral hooks and captions for short-form video about coding and tech. Email login, private per-user data, AI-generated hooks, and a feedback loop that learns which hook formats perform on my account. Dark developer-tool UI. Must work properly on a phone — I use it while filming. AUTH AND DATA OWNERSHIP - Email login (magic link or email + password, your call). Nothing is public. - EVERY database read and write is scoped to the logged-in user id, server-side. Never trust a user id sent from the client. My library, winners, and tracker data must be invisible to any other account. FIXED HOOK FORMAT TAXONOMY This list is a closed enum used everywhere in the app — generation, past-reel classification, the chart, the dashboard. Store it in one place and reference it; do not let the model invent new labels: contrarian_take, curiosity_gap, number_list, common_mistake, before_after, pov, direct_question, callout, story_open, stat_shock Display names are title-case ("Contrarian Take"). If the model ever returns a tag outside the enum, coerce it to the nearest one server-side rather than storing it. 1. GENERATE - Input: one-line topic (textarea, ~200 chars), a FORMAT select (Reel / Carousel / YouTube Short), and a TONE select (Blunt / Funny / Educational / Hype / Deadpan). - One button: GENERATE 20 HOOKS. - One single LLM call returns all 20 hooks as strict JSON — not 20 calls. Validate the JSON against a schema server-side and retry once on a parse failure. Show a skeleton grid of 20 shimmering rows while it runs. - Each hook object: { text, format (from the enum), score (0-100 integer), score_reason (one line, max ~90 chars), } - DIVERSITY CONSTRAINT, enforced in the prompt AND checked in code: the 20 hooks must cover at least 6 distinct formats, and no single format may appear more than 4 times. If the response violates this, re-ask once for replacements of the over-represented ones. - Hooks must be first lines only — spoken openers, no hashtags, no emoji, no "In this video". 2. THE TWO-SECOND RULE (computed in code, never by the model) For every hook, the server computes: - word_count = whitespace-split length - spoken_seconds = round(word_count / 2.5, 1) // ~150 spoken words per minute - too_long = word_count > 8 OR spoken_seconds > 2.0 Do NOT ask the LLM for word counts or durations — it will confidently get them wrong. Compute both from the returned text. In the UI, a too_long hook renders with a red left border, red duration pill, and a small "WON'T SURVIVE 2s" label. Everything else gets a green duration pill. Show the word count next to the seconds ("7 words · 2.8s"). 3. HOOK CARD AND LIST BEHAVIOUR Each hook is a card: the hook text large and readable, a format chip, a score badge, the score reason in muted small text, the duration pill, a copy button, and a star button. - Copy uses navigator.clipboard.writeText with a document.execCommand fallback, and flips the icon to a tick for ~1.2s. Copy must work on iOS Safari — it fires directly from the tap handler, not after an await. - Sort control: Score (high→low, default), Shortest first, Format. Sorting is client-side and instant — no refetch. - Star saves the hook to my Library with its topic, format, score, and generation date. Starring is optimistic in the UI and reconciled with the server response. - Filter chips at the top: ALL / FLAGGED / CLEAN. 4. LIBRARY Saved hooks, newest first, with search across hook text, a format filter, and an unstar action. Grouped by the topic they were generated for. Each row keeps its copy button. Empty state explains what starring does. 5. MY WINNERS (this is the important one) A section where I paste my past reels — one per line, "first line of the reel | view count" — into a single textarea, with a small example above it. Parse leniently: accept commas, tabs, or pipes as separators, strip "k"/"K" and "m"/"M" suffixes into real numbers, and show a per-line preview table of what was parsed before I confirm. Reject nothing silently — list unparseable lines back to me. On confirm: - One LLM call classifies every pasted first line into the enum. Batch them, do not call per line. - Aggregate per format: number of reels, MEDIAN views (not mean — one 400k outlier must not crown a format I used twice), and median views as a multiple of my overall median ("1.8x baseline"). - Require a minimum of 3 reels in a format before that format is allowed to influence anything. Formats below the threshold appear in the chart greyed out and labelled "not enough data". - Show a horizontal bar chart of median views by format, sorted descending, bars coloured by whether they are above or below my baseline. Do not pull in a heavy charting library for this — CSS-width bars are fine and stay readable on a phone. - Above the chart, one plain-English sentence: "Your contrarian takes do 1.8x your baseline. Your POV hooks do 0.4x." 6. BIASING FUTURE GENERATION Store a per-user format_weights map derived from Winners plus the Tracker (below). On generation, inject the top 3 qualifying formats into the LLM prompt as a soft instruction — "weight the distribution toward these formats, they measurably outperform for this creator" — while STILL respecting the diversity constraint in section 1. It biases the mix; it must never collapse to one format. COLD START: if I have fewer than 3 qualifying formats, use neutral weights and show a small banner on the Generate tab — "Add past reels in My Winners to personalise scoring." Never silently pretend one data point is a signal. On the Generate tab, a hook whose format is one of my winners gets a small ★ WINNER FORMAT chip. 7. CAPTION GENERATOR Selecting a hook (from Generate or Library) opens a caption builder. One LLM call returns four separately-stored fields: - hook_line (the hook, lightly adapted for text) - body (2-4 short lines of actual value, no fluff) - cta (one line — follow / save / comment a keyword) - hashtags (8-12, mixed reach: a few large, a few niche coding tags) Each of the four sections is its own stored field with its own REGENERATE button that re-asks for THAT FIELD ONLY, passing the other three as context so the tone stays consistent. Never round-trip the whole caption to change the CTA. Each section is editable inline, and edits persist. A live character counter shows the assembled caption length against Instagram's 2,200 limit, turning amber at 1,900 and red past 2,200. One COPY CAPTION button copies the assembled text with the sections separated by blank lines. 8. TRACKER A table of published hooks: hook text, format, predicted score, date published, and an editable ACTUAL VIEWS field. Adding an actual-views number: - recomputes my median views and the format weights, - stores the delta between predicted score and realised performance percentile, - and feeds a short "calibration note" into future generation prompts (e.g. "this creator's curiosity-gap hooks underperform their scores — score them more harshly"). Show, per format, a small "predicted vs actual" indicator: over-scored, under-scored, or calibrated. 9. DASHBOARD A strip of stat tiles at the top of the app: TOTAL HOOKS GENERATED, HOOKS SAVED, BEST-PERFORMING FORMAT (from Winners + Tracker), AVERAGE SCORE, and MEDIAN VIEWS. Each tile is a big number with a small uppercase label. Every stat is computed from my data only. DESIGN - Dark developer-tool aesthetic: near-black background (#0b0d10-ish), one elevated surface tone for cards (#14181d), hairline borders (#232a31), a single accent (electric lime or cyan) used only for actions and positive states, red reserved exclusively for the two-second flag. - Mono type (JetBrains Mono / IBM Plex Mono) for labels, chips, counters, and scores; a clean sans for hook text and captions. Uppercase letterspaced micro-labels. - No gradients, no glow, no rounded-pill everything. Sharp 6-8px radii. It should look like a terminal tool, not a marketing site. - Loading states are skeletons, not spinners. Errors are inline and specific, never a toast that disappears before I read it. MOBILE (not an afterthought — this is the primary device) - Single-column, thumb-reachable. The GENERATE button is sticky at the bottom of the viewport on the Generate tab. - Hook cards stack full-width; copy and star are large tap targets (min 44px), placed on the right where a thumb lands. - Bottom tab bar for the five sections, not a hamburger. - Text inputs use font-size 16px so iOS does not zoom on focus. - The winners chart scrolls vertically, never horizontally. - Test that nothing overflows at 375px wide.

Why the prompt is written this way

The version of this prompt I actually started with was a paragraph long and the app it produced looked right and was quietly useless. Seven clauses turned it into a tool I use.

Never let the model count

This is the single most important line in the prompt: compute word_count and spoken_seconds in code, never ask the LLM.

The whole premise of the app is the two-second flag. Ask the model to return an estimated duration and it will happily tell you a nine-word hook is 1.8 seconds, because token-level arithmetic is exactly what language models are worst at and it has no incentive to fail loudly. You end up with a red-flag system that is wrong maybe one time in six — which is worse than no flag at all, because you stop checking.

The rule is trivial the moment you move it: split on whitespace, divide by 2.5 words per second, compare against the thresholds. Deterministic, instant, free, and identical every time you regenerate. Anything in an AI app that can be a pure function of the model's output should be one.

A closed enum, or the analysis can never aggregate

Ten format names, fixed, referenced everywhere. Without that, the model tags hooks with whatever phrasing feels apt in the moment — "contrarian take", "hot take", "controversial opinion", "contrarian angle" — and every one of those is a separate row in your database.

For a generator that would be a cosmetic annoyance. Here it breaks the feature the app exists for. My Winners groups past reels by format and computes a median per group; if your taxonomy has drifted into forty near-synonyms, every group has one member, no group clears the three-reel threshold, and the chart is a wall of "not enough data" forever. The enum is what makes the loop closeable.

Note also the coercion clause — if the model returns a tag outside the enum, coerce it server-side. Not "reject", not "throw". A single stray label should not fail a whole generation.

Median, not average

MEDIAN views (not mean — one 400k outlier must not crown a format I used twice).

View counts are about the most skewed data you will ever average. One reel that broke containment is worth thirty normal ones, and if you take the mean, the format that reel happened to use becomes your "best performing format" permanently — and then the app biases every future generation toward it. You have built a machine that mistakes a single lucky post for a strategy.

The three-reel minimum is the other half of the same defence. Together they mean the app only tells you something is working when it has actually worked more than once.

Say what happens before there is data

COLD START: if fewer than 3 qualifying formats, use neutral weights and show a banner.

Personalisation features are always specified for the steady state and the first-run behaviour is left to chance. What you get is an app that, on day one with zero past reels, either crashes on an empty aggregate or — much worse — computes weights from nothing and presents them as insight. A user's first session is the one where they decide whether the thing is credible.

The banner is also the app's only honest way to ask for the data it needs. It tells you the feature exists, that it is off, and exactly what turns it on.

Four caption fields, not one caption string

"I can regenerate any section on its own" is a UI request that is really a data model request. If the caption is stored as one blob of text, then regenerating the CTA means sending the blob back, asking for a new version, and getting back a whole caption where the body has subtly changed too — so your edits are gone and you cannot tell which parts moved.

Naming the four fields — hook_line, body, cta, hashtags — makes each one independently addressable and independently editable. The clause that the other three are passed as context is what keeps the regenerated section in the same voice as the ones you kept. Without it each regeneration drifts and the caption slowly stops sounding like one person wrote it.

One call for twenty hooks, with the diversity rule enforced twice

Twenty separate calls is twenty times the cost and latency, and — because each call is blind to the others — you get near-duplicates. One call returning a JSON array is fast and cheap, but has the opposite failure: the model finds one angle it likes and gives you five rewrites of it.

Hence the constraint stated in the LLM prompt and checked in code: at least six distinct formats, no format more than four times. The in-prompt version gets it right most of the time; the code check is what makes it true. That double-enforcement pattern — instruction plus validation plus one targeted retry — is the general shape for anything you need an LLM to actually guarantee.

Scope every query server-side

Every read and write is scoped to the logged-in user id, server-side. Never trust a user id sent from the client.

"Give me email login so my library is private" describes a login screen. It does not, on its own, produce isolation. The default generated shape is a REST endpoint that takes a user id as a query parameter and returns that user's hooks — which is a login screen in front of a shared database, and changing one number in a URL reads someone else's library. Say where the trust boundary is, or you get authentication without authorisation.

The mobile clauses are the ones I would not drop

I use this standing in front of a tripod with a phone in one hand, so the mobile section is not a responsive-design afterthought. Four small clauses do almost all of the work:

Knobs to turn first

If it comes out wrong

Recap

If you want more prompts in this shape, there is a single-file sticky-notes app, a punch-through ice wall in Three.js, and a case study of a full multi-tenant SaaS built on Emergent.

← Portfolio · Blog