Hawk
Solo build · creator tooling
An AI decision engine for TikTok Shop affiliate creators: one action brief a day, then double-down or kill verdicts against their own numbers.
- Role
- Solo build
- Timeline
- Oct 2026 – present
- Stack
- Next.js, Expo, Hono, Supabase, Claude

Overview
Hawk is an AI decision engine for TikTok Shop affiliate creators. Every day it produces one action brief: the product to promote, the hook to open with, the format, a script, execution steps and the evidence behind the call, with three ranked backups.
Creators log what they posted. Hawk issues double-down, kill or watch verdicts against their own median earnings per thousand views, and the next brief adapts.
It is a two-day build of about 14K lines of TypeScript: a Next.js 16 web app, an Expo SDK 57 app and a Hono API over one platform-neutral core package, runnable with no account and no server in demo mode. Every number here is counted from the repo.
What I built
- A monorepo with a Next.js web app, an Expo mobile app and a Hono API, all sharing one platform-neutral core package shipped as TypeScript source: domain types, the demo intelligence catalog, analyst scoring and brief generation, verdicts, earnings math, auth helpers and onboarding logic.
- A deterministic, evidence-cited analyst: six weighted factors per product, feedback from the creator's own videos, five-tier hook selection, and a generator that throws rather than ship a recommendation with no source evidence. The optional LLM only rewrites script text.
- One persistence seam: a HawkStore interface with a LocalStore over any key-value storage and a SupabaseStore over Postgres with owner-only row-level security, so demo mode is the real product.
- Supabase Auth ported as product flows after auditing FitFo's auth: phone and email codes, password reset by code, AES-encrypted sessions on mobile, httpOnly pending-verification cookies on web, and in-process rate limits in front of GoTrue's own.
- An interview-before-account onboarding transplanted from FitFo's carousel: 13 slides, a local draft, sign-up from the review slide, and a staged setup that seeds demo history and builds the first brief.
By the numbers, counted from the repo
Architecture
- Web
- Next.js 16 App Router, Tailwind v4, @supabase/ssr cookies refreshed in a proxy, server actions for every auth call, demo session as a 30-day httpOnly cookie
- Mobile
- Expo SDK 57 on Expo Router with Stack.Protected guards, React Native 0.86, SQLite-backed localStorage, AES-256-CTR session storage keyed from the keychain
- API
- Hono 4 on Node via tsx: health, demo catalog, script rewrite with OpenAI or Claude, bearer auth through Supabase, per-user rate limit, 32 KB body cap, strict CORS
- Core
- @hawk/core: HawkStore seam (LocalStore, SupabaseStore), analyst, verdicts, earnings, onboarding slides and steps, auth validation and pending state, TikTok provider stub
- Data
- Supabase Postgres: 2 migrations, 8 tables, 11 policies, profiles trigger mirroring email and phone; seed SQL generated from the TypeScript catalog and parsed with libpg_query in CI
How it's built
- 01
The analyst
A brief is a pure function over the catalog, the creator's profile and their logged videos. Nothing in it comes from a model.
Scoring
- Six factors in the unit range per product: niche fit (1 or 0.25, so cross-niche products still rank when a niche is empty), conversion (rate over 5%), room to compete (1 minus saturation), earning (price times commission, saturating at $10 an order), trend, and style fit (1 if a hook matches the creator's formats, else 0.6).
- Weights shift by level: beginners lean on niche fit and saturation, grinders balance earning and trend, operators weight earning at 0.30. Feedback from the creator's own videos is additive: +0.15 per double-down capped at +0.3, -0.2 per kill capped at -0.4, and -0.1 fatigue when three posts produced no double-down.
Hooks, scripts and evidence
- Products sort by total then id for determinism. For each of the top four, hook selection walks five tiers (same niche and style, generic and style, same niche, generic, anything), ranks within a tier by CTR times 10 plus view-through, and never reuses a hook inside one brief.
- Scripts fill hook placeholders from a per-category problem table, add per-format body beats for six formats, pick one of three CTAs, and emit six execution steps keyed to posting frequency.
- If a product has no catalog evidence and no evidence from the creator's own numbers, generation throws. Brief ids are user-scoped per date and product; regenerating preserves statuses by id and pins any accepted or posted pick that fell out of the new top four.
- 02
Verdicts and earnings
Double down, kill, watch
- Earnings per thousand views are commission cents over views times 1,000, zero for zero views. Zero views is always watch. With fewer than two comparables, absolute thresholds apply: double-down at $3.00 per 1K, kill at zero commission and 2,000 views.
- Otherwise the creator's own median per-1K is the bar: double-down at 1.5 times the median and at least 50 cents, kill at 1,000 views or more and 0.4 times the median or less. Every reason string embeds the numbers used, and verdicts are backfilled only for videos that lack one.
Money
- Settled is settled minus clawbacks; the 30-day figure sums signed amounts; per-product nets commission and GMV and counts non-clawback orders; a demo flag is true only when every event is demo-sourced.
- Weekly history buckets seven days ending at tomorrow's local midnight, clawbacks subtracting and pending tracked separately; a hand-logged video mirrors its commission as a pending event with a stable id so edits upsert and deletes clean up.
- 03
The store seam
Every screen on web and mobile talks to one interface, so the demo is not a mock. It is the product on a different backend.
LocalStore
- One JSON document under a versioned key over any sync-or-async key-value storage: browser localStorage on web, SQLite-backed localStorage on device. First load is a shared in-flight promise and a mutation that lands first wins; all mutations and reset go through a promise queue so overlapping awaits never clobber each other, with tests for the first-load race and a reset mid-mutation.
SupabaseStore and demo activity
- Every query filters by user id on top of RLS. Saving briefs upserts first and then deletes same-date rows missing from the new set, so a video referencing a brief never loses its foreign key. Integration metadata (TikTok tokens) is never selected into the client.
- Demo history is deterministic per profile (FNV hash into a seeded PRNG): grinders get 12 videos over 45 days, operators 30 over 60, beginners none; views are heavy-tailed with 15% breakouts, commission arrives in one to three order batches that settle after 14 days, 20% of settled multi-order videos get a clawback, and two payouts land 60% paid and the rest scheduled.
- 04
Auth
An audit of FitFo's auth found self-minted 30-day JWTs with no refresh or revocation and plaintext mobile tokens. Hawk keeps Supabase Auth and ports the flows.
Flows and limits
- Phone with an SMS code, email with a password and a 6-digit confirmation code, email-code login and recovery by code; no magic links, which avoids deep-link and PKCE plumbing on mobile. Phones normalize to E.164 across a 26-country picker; passwords are 8 to 128 characters.
- Server actions log nothing. A duplicate email signup is detected by an empty identities array; an unconfirmed password login resends the code instead of dead-ending; email-code login treats signups-not-allowed as success so unknown addresses reveal nothing; a safe-next check rejects non-relative paths, backslashes, control characters and credentials.
- In-process limits in front of GoTrue's own: sends 20 per IP per 10 minutes and 5 per identifier per 15, verifies 30 and 10, passwords 30 and 8; a 60-second resend cooldown is enforced server-side; codes expire after 600 seconds; access tokens last an hour with rotating refresh and a 10-second reuse window.
Sessions
- Web keeps the pending verification in a 15-minute httpOnly cookie so identifiers never land in URLs; the reset-password page is protected because it needs the recovery session.
- Mobile sessions are AES-256-CTR encrypted with a 32-byte key in the device keychain and a fresh IV per write, prefixed with a sentinel so a wrong-key decrypt is detectable; only the ciphertext reaches SQLite, because SecureStore caps values at 2 KB and two JWTs exceed it. A lost key reads as signed out, not a crash.
- 05
Onboarding
Interview before account
- 13 slides alternating question and show (niche, proof, goal, analyst, level, always-on, rhythm, verdicts, formats, evidence, name, handle, review), with copy and order in core so web and mobile cannot drift; seven validated steps with a handle regex and a profile builder that throws on an incomplete summary.
- Answers persist to a local draft on every change and the slide index resumes. The review slide starts the on-device session in demo mode or opens sign-up in Supabase mode; after the code is accepted the wizard saves the profile, seeds demo activity, forces the first brief and clears the draft behind a staged setup screen with a 5.2-second minimum so the progress reads as real work.
- 06
API, data and tooling
The rewrite endpoint
- The Hono app is built without binding a port so tests call it directly. The rewrite route verifies a bearer token through Supabase with only the anon key, runs as a demo user in demo mode, limits 10 per minute per user, rejects bodies over 32,000 bytes by both header and measured length, and shape-checks every string because the body goes straight into a prompt (12 evidence items, 8 beats, 500 characters max).
- OpenAI is used when its key is set (gpt-4.1-mini, JSON mode, temperature 0.4), else Claude; neither can touch scores, ranks or evidence, the system prompt forbids inventing numbers, and malformed output keeps the original script. No key at all is a 501, not a fake.
Data and checks
- Two migrations define 8 tables with check constraints on enums and non-negative money, RLS on all of them, four owner-only policies on profiles, read-for-authenticated on the catalog tables, and owner-only on the rest; a trigger creates a profile per auth user and mirrors email and phone with unique partial indexes.
- The SQL seed is generated from the TypeScript catalog and every migration plus the seed is parsed with libpg_query before any Postgres runs. Vitest covers the evidence invariant, pairing across all niches and levels, verdict thresholds, clawback math, store race semantics, encrypted-storage round trips and the API's auth, size and rate limits; Playwright scripts cover the web demo, local-stack auth with Mailpit codes, and the mobile web export.
Decisions
- 01
One store interface, two backends
Every screen talks to a HawkStore. Demo mode and Supabase mode share the same code, so the demo is the real product and mobile cost almost nothing to add.
- 02
Demo data is labeled, never faked
Rows carry a demo source, the UI shows Demo data chips, and the TikTok provider throws a typed not-configured error instead of pretending to connect.
- 03
Deterministic analyst, LLM at the edge
Scores and evidence are pure functions; the model only rewrites script text behind an optional, rate-limited, size-capped endpoint. The cost is a 24-product hand-written catalog until ingestion exists.
- 04
Generated seed data
The SQL seed is generated from the TypeScript demo catalog, so demo mode and the database can't drift.
- 05
Keep Supabase Auth, port the flows
Rather than mint tokens, Hawk maps each flow to a GoTrue primitive and adds only validation, an in-process limiter and screens. Codes everywhere, no magic links.
- 06
Core as TypeScript source
No build step and no Turborepo; Next transpiles it, Metro bundles it. The tradeoff is that the API runs through tsx in production because Node will not strip types from a workspace package.
Edge cases the code handles
- Zero views is always watch; fewer than two comparables fall back to absolute thresholds; an empty niche still ranks through the 0.25 niche-fit floor.
- Hooks are unique within a brief, brief ids are user-scoped, and accepted or posted picks survive a regenerate, which the e2e suite checks across a reload.
- Today's key is the device's local date and weekly buckets end at tomorrow's local midnight; logged videos are stamped local noon before serialization so dates never shift.
- GoTrue returns an empty string for a missing contact channel, which both apps normalize; a duplicate signup is detected by an empty identities array.
- A wiped keychain makes the encrypted mobile session unreadable and the app treats it as signed out.
- Cookies are secure in production unless explicitly disabled for an HTTP host, and a 0.0.0.0 bind host is replaced with the forwarded host so redirects work behind next start.