The Prompt I Used to Build Hook Lab in Emergent (20 Scored Instagram Hooks, Biased Toward What Already Worked)
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.
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:
-
Copy fires directly from the tap handler. iOS Safari
only allows clipboard writes inside a real user gesture. Put an
awaitbeforewriteTextand the gesture context is gone — the button animates, nothing lands on the clipboard, and it works perfectly on your desktop while you debug it. - 16px inputs. Anything smaller and iOS zooms the whole page on focus, then leaves you scrolled sideways. One CSS value separates "types a topic in" from "fights the viewport".
- Sticky generate button, bottom tabs. The two controls I touch most, in the place my thumb already is. A hamburger menu on a five-section app is two taps for every navigation.
- 44px tap targets on copy and star, right-aligned. These are the actions the whole app exists to serve. A 24px icon in a card corner is a miss two times in five while holding a phone one-handed.
Knobs to turn first
- Words per second (2.5). The number the red flag hangs on. 2.5 wps is a fast, punchy delivery — roughly 150 wpm. If you speak more deliberately, drop it to 2.2 and watch the flags multiply. Tune this against your own footage once, then leave it alone.
- The 8-word ceiling. Deliberately harsher than the two-second rule alone. Eight words is a first line you can read off a screen before you have decided to; ten is a sentence. Both thresholds together is stricter than either — that is the intent.
- Minimum reels per format (3). The honesty dial. Raise it to 5 and the chart stays empty longer but never lies; lower it to 2 and you get personalisation that is mostly noise.
- Diversity floor (6 formats, max 4 each). Loosen it and the twenty hooks converge on whatever the model finds easiest. Tighten it much further and you get formats forced onto topics they do not fit — a before/after hook for a topic with no before.
- Score distribution. If every hook comes back 82-89, the score is decoration. Ask explicitly for spread and for the reason to justify the number, and be willing to say "at most five hooks may score above 85".
If it comes out wrong
- Every hook scores in the high 80s. The model has no rubric and defaults to flattery. Give it anchors — what a 40 looks like, what a 95 looks like — and cap how many can clear 85.
- Durations are obviously wrong. The word count and seconds are coming from the model instead of being computed from the returned text. This is the default failure and the reason that clause is worded so bluntly.
- The winners chart says "not enough data" with 30 reels pasted in. Format tags are drifting outside the enum, so the groups are fragmenting. Check what is actually stored in the format column.
- One format has taken over the whole generation. The bias weights are being applied as a hard filter rather than a soft preference, and the diversity check is not running after the bias is injected. It has to run on the final set.
-
Copy works on desktop, silently fails on the phone.
There is an
awaitbetween the tap andwriteText, or the page is being served over plain HTTP. - Regenerating the CTA changed the body too. The caption is stored as one string. Four fields, four endpoints.
- Pasting past reels drops half of them. The parser expects exactly one separator. Accept pipes, commas and tabs, handle "12k", and show the parse preview so failures are visible instead of silent.
- A second account can see your library. A user id is being read from the request instead of the session. Fix this before you put anything real in it.
- Generation takes 40 seconds and looks frozen. Twenty individual calls, or one call with no skeleton state. Batch it, and render the twenty placeholder rows immediately.
- Hooks come back with hashtags and emoji in them. The model is writing captions, not first lines. "Spoken openers only, no hashtags, no emoji" needs to be in the generation prompt, not just in your head.
Recap
- Anything that can be a pure function of the model's output should be computed in code. Word counts and durations are not AI work.
- Fix the taxonomy as a closed enum up front, or nothing downstream can ever aggregate.
- Median, not mean, for anything as skewed as view counts — plus a minimum sample size before you let it influence behaviour.
- Specify the cold-start state. It is the first thing a user sees and the last thing anyone writes down.
- "Regenerate one section" is a data model decision. Name the fields.
- For anything you need the LLM to guarantee: instruct it, validate it in code, retry once. Instruction alone is a preference.
- "Add email login" is not "make it private". Say that queries are scoped server-side.
- If the primary device is a phone, write the mobile clauses as requirements — sticky primary action, bottom tabs, 44px targets, 16px inputs — not as "make it responsive".
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.