Vibecode AppGrowKit
track this build4 phases, 12 steps, beginner friendly0%Split the product in half and the answer changes. The screenshot generator is genuinely one-shottable: it is an image model behind a prompt, and a weekend of iteration gets you panels good enough to ship. The tracking half is too, if you only care about apps you own, because Apple's public endpoints hand you ratings, reviews, and chart positions for free. What you cannot build is the part you would be paying for: a catalog of 1.7 million apps across 36 storefronts, with 22 million rating observations and 11 million rank observations accumulated over months. 1.25 million of those apps have more than one day of history and 960,000 have ten days or more, which is the difference between a table of apps and a time series. You can start collecting today, and in six months you will have six months of it. Competitor intelligence, keyword difficulty, and revenue estimates are all reads over that history, so they arrive empty on day one and stay thin for a season. Build it if you track a handful of your own apps. Pay if you need to answer questions about apps you have never opened.
You are building a lean indie version of AppGrowKit. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # AppGrowKit · indie build A personal App Store tracker and screenshot generator whose hard problems are polling an unauthenticated public endpoint politely, keeping an insert-only time series that is worth something in six months, and catching what an image model invents before it reaches a store listing. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 and TypeScript with a small local interface, one machine | this is a tool for your own apps, not a service | | Storage | SQLite, insert only, with raw responses kept on disk | the history is the product and it has to be reconstructable | | Collection | A scheduler with a rate limit read from the current published figure | the endpoints need no key, so throttling arrives as silence | | Images | An image model for panel composition, real text composited afterwards | model-drawn glyphs are the tell and they fail at thumbnail size | | Review | A vision model checking every render against an explicit list | you will not spot a sliced element in a six panel set by eye | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node 22 or newer with TypeScript** · free - Why: Runs the collector, the interface and the screenshot pipeline. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A scheduler that runs whether or not you are logged in** · free - Why: A rank tracker that only polls when you happen to have a terminal open produces a history full of holes, which is the one thing it cannot recover from. - Get it: A system timer or service on an always-on machine, not a terminal loop. - Verify: A poll row exists for every scheduled slot over three consecutive days - [ ] **An API key for an image model** · pay per image; measure your own cost per set on the first run - Why: Panel composition is the only part of the build that costs money per use, which is why it has a ceiling. - Get it: Create a key with your chosen provider and set a spend limit in their console as well as in this app. - Verify: One panel renders and a cost row is written - [ ] **An API key for a vision model** · pay per image, smaller than generation - Why: The critic pass is what stops an invented award badge reaching a store listing, and it needs a model that can read a rendered image. - Get it: A multimodal model from any provider, which may be the same key as the image model or a different one. - Verify: A deliberately garbled render fails at least one check - [ ] **The store's current screenshot specification page** · free - Why: Required dimensions and device classes change, and a set built to last year's numbers is rejected at submission. - Get it: Open the store's published specification on the day you build, record the URL, the date and the figures in the repository. - Verify: The recorded figures match what the exporter produces ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Competitor intelligence and keyword difficulty. - Revenue and download estimates. - Tracking apps you do not own. - Automated store submission. - months of rank, rating, and keyword history you cannot backfill - the 1.7M-app, 36-storefront catalog behind competitor lookups and keyword difficulty - revenue and download estimates, which are calibrated against that catalog - keyword volume and competition scores, which need a corpus to be relative to - the MCP server that answers ASO questions from Claude, Cursor, or ChatGPT - an accuracy pass on generated screenshots: the QA critic, best-of-2 scoring, and exact-size output If one of those is essential to you, that is the reason to keep paying for AppGrowKit, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · AppGrowKit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an App Store screenshot generator and a rank tracker for apps you own to replace AppGrowKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the entire reason this build is worth starting today. Nothing in the tracking half of this app is ever updated. Every poll is an insert carrying its timestamp, the exact URL requested, the HTTP status and the raw response, including the polls that failed. Rank and rating history is the only asset here you cannot buy, borrow or backfill: a row you overwrote is a day that does not exist any more. If you find yourself writing an upsert in this codebase, you have deleted the product. Second, do not compute a number you do not have the data for. Competitor intelligence, keyword difficulty and revenue estimates are reads over a catalogue of millions of apps with months of accumulated history behind them. On day one you have your own apps and one day of observations. Those panels ship as empty states that say exactly what data they would need and how long you would have to collect it, because a plausible figure derived from a single day teaches you to trust something that is not a measurement of anything. The corollary for the screenshot half: an image model will happily draw star ratings, award badges and text that is nearly-but-not-quite words. Those are claims about your app on a store listing. Composite real text yourself and reject any render that invents a badge. ### Stack (fixed, do not substitute) - Runtime: Node 22 and TypeScript with a small local interface, one machine · this is a tool for your own apps, not a service - Storage: SQLite, insert only, with raw responses kept on disk · the history is the product and it has to be reconstructable - Collection: A scheduler with a rate limit read from the current published figure · the endpoints need no key, so throttling arrives as silence - Images: An image model for panel composition, real text composited afterwards · model-drawn glyphs are the tell and they fail at thumbnail size - Review: A vision model checking every render against an explicit list · you will not spot a sliced element in a six panel set by eye ### Data model (create this before Phase 1) - apps(track_id, storefront, name, bundle_id, added_at) · the apps you own, listed by hand - polls(id, at, endpoint, url, http_status, request_ms, response_sha256, raw_path) · one row per request, failures included - ratings(poll_id, track_id, storefront, average, count, app_version) · insert only, never reconciled - ranks(poll_id, track_id, storefront, chart, genre_id, position) · a missing position is a row with a null and a reason, not an absent row - keyword_positions(poll_id, keyword, storefront, track_id, position, results_returned) - reviews(id, track_id, storefront, review_id, review_at, first_seen_at, rating, title, body, app_version) - screenshot_runs(id, at, device, prompt_version, model_id, cost_minor, candidate_count, chosen_candidate) - screenshot_checks(candidate_id, check_name, passed, notes) · the critic's findings kept whether it passed or failed ### Phase 1 · A poller that behaves and keeps its receipts Build: A scheduler polling the public lookup, search, chart and review endpoints for your own apps. Send a real identifying user agent. Read the current published rate limit before choosing an interval and leave headroom under it. Write a poll row for every request with its status, duration and the path to the raw response body, and back off on anything that is not success. Trap: Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. Trap: Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. Done when: The scheduler never exceeds the configured rate, asserted with a controlled clock in a test. Every request writes a poll row including its status and the raw body path, failures included, verified by pointing it at a server that returns errors. A non-success backs off exponentially and the next attempt is recorded with the delay. A day with no successful poll renders as a gap in every chart, not as a flat line. Do not build yet: No charts, review diffing or screenshot work yet. ### Phase 2 · A time series nothing is allowed to overwrite Build: Store each observation against its poll id with no unique key that would tempt an upsert. Chart rating average, rating count, chart position and keyword position over time. Show the count of observation days prominently, and refuse to draw a trend line below a configured minimum number of days. Trap: Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. Trap: Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. Done when: Two polls for the same app on the same day produce two observation rows, asserted. No table in the tracking half has an update or upsert statement anywhere, verified by searching the source. Charts break across days with no successful poll and label the gap. A trend line is refused below the configured minimum day count and the interface says how many more days are needed. Do not build yet: No review diffing, screenshots or cost work yet. ### Phase 3 · New reviews since you last looked Build: Poll the public review feed per storefront, key each review on its own identifier, and store first_seen_at separately from the review's own date. Show what arrived since your last visit. Treat a changed body for an existing identifier as a new row rather than an edit in place. Trap: Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. Done when: Polling the same feed twice adds no duplicate reviews, tested against a fixture served twice. A review whose body changes produces a second row and both are visible in order, tested. first_seen_at is the poll time and review_at is the store's date, and the two are never conflated in the interface. A review that disappears from the feed stays in the database and is marked rather than deleted. Do not build yet: No screenshot pipeline or spending controls yet. ### Phase 4 · Panels, and a critic that rejects them Build: Pass your real app screens as reference images so the interface inside the phone frame stays faithful. Composite every headline as real text afterwards rather than letting the model draw glyphs. Send each render to a vision model with an explicit checklist and re-render once with the failures quoted back. Resize to the exact dimensions the store currently publishes for the display sizes you are submitting, read from the specification page on the day you build. Trap: An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. Trap: Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. Done when: Every candidate has check rows for each item on the list, stored regardless of outcome. A candidate failing any check is either re-rendered once with the failures quoted or held for review, never shipped silently. All headline text in the output is composited rather than generated, verified by producing a set with the text layer disabled. Output dimensions match the figures read from the store's current specification page, with the page URL and date recorded in the repository. Do not build yet: No cost ceiling, export or empty state work yet. ### Phase 5 · Spending, exports and honest empty states Build: Track spend per month against a ceiling and stop generating when it is reached, leaving the tracker running. Export every observation table as CSV and reimport it into an empty database. Ship the competitor, keyword difficulty and revenue panels as empty states naming the data they would need. Put the collection start date on the front page. Trap: A screenshot pipeline with two model calls per candidate and a re-render on failure costs more per iteration than you expect, and an experiment left running overnight is a bill. Count every call, hold the total against a monthly ceiling, and refuse to start a run that would cross it rather than stopping halfway through a set. Trap: Filling an empty competitor panel with something derived from your own single day of data is how a placeholder becomes a number somebody plans around. Ship the empty state with a sentence about what a catalogue of that size actually requires, and leave it empty until you have it. Done when: Reaching the monthly ceiling stops image generation with a clear message and leaves collection running, tested at the boundary. Export produces every observation table as CSV and reimports into an empty database with identical row counts, tested. Panels requiring a catalogue render as empty states with an explanation and contain no computed figure, verified by review. The front page shows the collection start date and the number of observation days. Do not build yet: Nothing after this. ### Out of scope (and why) - Competitor intelligence and keyword difficulty scores, which are reads over a catalogue of millions of apps across dozens of storefronts with months of history and cannot be approximated from your own apps. - Revenue and download estimates, which are calibrated against that same catalogue and are a model of other people's data, not a measurement of yours. - Tracking apps you do not own, which turns a polite personal poller into a crawler with a very different relationship to the endpoints it depends on. - Automated store submission, which needs the store's authenticated publishing interface and its own review process rather than a folder of images. ### README must contain - That every table in the tracking half is insert only and that an upsert would destroy the product - That the tracker is worth little until it has been collecting for a month, and what the front page day count means - Where the current rate limit figure came from, with the URL and the date it was read - That headlines are composited as real text and that every render is checked for invented ratings and badges - The two API keys, the cost per screenshot set measured rather than estimated, and the monthly ceiling ===== AGENTS.md ===== # Agent instructions · AppGrowKit indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 and TypeScript with a small local interface, one machine, SQLite, insert only, with raw responses kept on disk, A scheduler with a rate limit read from the current published figure, An image model for panel composition, real text composited afterwards, A vision model checking every render against an explicit list. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. ===== BUILD_PLAN.md ===== # Build plan · AppGrowKit A personal App Store tracker and screenshot generator whose hard problems are polling an unauthenticated public endpoint politely, keeping an insert-only time series that is worth something in six months, and catching what an image model invents before it reaches a store listing. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A poller that behaves and keeps its receipts Collection runs on a schedule, stays under the published rate, and records every request including the failures. ### Steps 1. Read the current published rate limit and set the interval Do not copy a number from a blog post, including this one. Files: `src/collect/schedule.ts`, `docs/rate-limit.md` 2. Write a poll row for every request The failures are what make a gap explainable. Files: `src/collect/poll.ts`, `tests/poll.test.ts` 3. Back off exponentially on any non-success Silence is what throttling looks like from here. Files: `src/collect/backoff.ts` ### Done when - [ ] The configured rate is never exceeded under test - [ ] Every request writes a poll row, including failures - [ ] Non-success backs off and the delay is recorded - [ ] A day with no successful poll renders as a gap ### Watch out - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. ## Phase 2 · A time series nothing is allowed to overwrite Ratings, ranks and keyword positions accumulate as insert-only observations tied to the poll that saw them. ### Steps 1. Insert observations, never reconcile them The chart is the product; the current value is a by-product. Files: `src/store/observations.ts`, `tests/insert-only.test.ts` 2. Break the line across missing days A tidy chart with invented points is worse than a gappy one. Files: `src/ui/charts.tsx` 3. Refuse a trend below a minimum day count Two points is not a trend however tempting the slope is. Files: `src/analysis/trend.ts` ### Done when - [ ] Two same-day polls yield two rows - [ ] No update or upsert exists in the tracking code - [ ] Charts break and label gaps - [ ] Trend lines are refused below the minimum day count ### Watch out - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. ## Phase 3 · New reviews since you last looked The review feed is diffed by review identity so nothing is double counted and nothing is missed. ### Steps 1. Key on the review identifier, never on position The feed reorders and pages without telling you. Files: `src/collect/reviews.ts`, `tests/reviews.test.ts` 2. Keep first seen separate from the review's own date One is your observation and one is theirs. Files: `src/store/reviews.ts` 3. Retain reviews that vanish from the feed Disappearing is information, not a reason to delete. Files: `src/collect/reviews.ts` ### Done when - [ ] Polling twice creates no duplicate reviews - [ ] An edited review produces a second row - [ ] First seen and review date are distinct in the interface - [ ] A vanished review is marked, not deleted ### Watch out - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. ## Phase 4 · Panels, and a critic that rejects them Screenshot sets are generated from your real screens and every candidate is checked before you see it. ### Steps 1. Pass the real screens as reference images Otherwise the phone in the panel runs an app you do not have. Files: `src/screens/generate.ts` 2. Composite headlines as real text after generation Model-drawn type is the thing reviewers notice first. Files: `src/screens/compose.ts` 3. Store every critic check, passed or failed The failures are how you improve the prompt. Files: `src/screens/critic.ts`, `tests/critic.test.ts` ### Done when - [ ] Every candidate has stored check rows - [ ] A failing candidate is re-rendered or held, never shipped - [ ] Headlines are composited, not generated - [ ] Dimensions match the currently published specification ### Watch out - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. ## Not in this build - Competitor intelligence and keyword difficulty. - Revenue and download estimates. - Tracking apps you do not own. - Automated store submission. ## After v1, if you want it - A weekly digest of what moved, built from the same observations rather than a second pipeline - Annotating the timeline with your own release dates, which is the only causal information you actually have - A second storefront for an app you already ship there, which tests the storefront dimension before it matters ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Apps, polls and every observation. This is the asset; back it up daily. DATABASE_PATH=./data/aso.db # Required. Raw bodies keyed by hash, so any observation can be re-derived. RAW_RESPONSE_DIR=./data/raw # Required. Derived from the currently published rate limit with headroom. Recheck it. POLL_INTERVAL_MS=3000 # Required. Identify yourself with a real contact address on every request. USER_AGENT=my-aso-tracker/1.0 (contact@example.com) # Required · secret. Panel generation. Also set a provider-side spend limit. IMAGE_API_KEY=sk-...redacted # Required · secret. The critic pass. May be the same provider as generation. VISION_API_KEY=sk-...redacted # Required. Generation stops here for the month; collection keeps running. MONTHLY_COST_CEILING=20.00 # Required. Below this, the interface shows points and refuses to draw a trend. MIN_TREND_DAYS=14
You are building a lean indie version of AppGrowKit. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # AppGrowKit · indie build A personal App Store tracker and screenshot generator whose hard problems are polling an unauthenticated public endpoint politely, keeping an insert-only time series that is worth something in six months, and catching what an image model invents before it reaches a store listing. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 and TypeScript with a small local interface, one machine | this is a tool for your own apps, not a service | | Storage | SQLite, insert only, with raw responses kept on disk | the history is the product and it has to be reconstructable | | Collection | A scheduler with a rate limit read from the current published figure | the endpoints need no key, so throttling arrives as silence | | Images | An image model for panel composition, real text composited afterwards | model-drawn glyphs are the tell and they fail at thumbnail size | | Review | A vision model checking every render against an explicit list | you will not spot a sliced element in a six panel set by eye | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node 22 or newer with TypeScript** · free - Why: Runs the collector, the interface and the screenshot pipeline. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A scheduler that runs whether or not you are logged in** · free - Why: A rank tracker that only polls when you happen to have a terminal open produces a history full of holes, which is the one thing it cannot recover from. - Get it: A system timer or service on an always-on machine, not a terminal loop. - Verify: A poll row exists for every scheduled slot over three consecutive days - [ ] **An API key for an image model** · pay per image; measure your own cost per set on the first run - Why: Panel composition is the only part of the build that costs money per use, which is why it has a ceiling. - Get it: Create a key with your chosen provider and set a spend limit in their console as well as in this app. - Verify: One panel renders and a cost row is written - [ ] **An API key for a vision model** · pay per image, smaller than generation - Why: The critic pass is what stops an invented award badge reaching a store listing, and it needs a model that can read a rendered image. - Get it: A multimodal model from any provider, which may be the same key as the image model or a different one. - Verify: A deliberately garbled render fails at least one check - [ ] **The store's current screenshot specification page** · free - Why: Required dimensions and device classes change, and a set built to last year's numbers is rejected at submission. - Get it: Open the store's published specification on the day you build, record the URL, the date and the figures in the repository. - Verify: The recorded figures match what the exporter produces ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Competitor intelligence and keyword difficulty. - Revenue and download estimates. - Tracking apps you do not own. - Automated store submission. - months of rank, rating, and keyword history you cannot backfill - the 1.7M-app, 36-storefront catalog behind competitor lookups and keyword difficulty - revenue and download estimates, which are calibrated against that catalog - keyword volume and competition scores, which need a corpus to be relative to - the MCP server that answers ASO questions from Claude, Cursor, or ChatGPT - an accuracy pass on generated screenshots: the QA critic, best-of-2 scoring, and exact-size output If one of those is essential to you, that is the reason to keep paying for AppGrowKit, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · AppGrowKit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an App Store screenshot generator and a rank tracker for apps you own to replace AppGrowKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the entire reason this build is worth starting today. Nothing in the tracking half of this app is ever updated. Every poll is an insert carrying its timestamp, the exact URL requested, the HTTP status and the raw response, including the polls that failed. Rank and rating history is the only asset here you cannot buy, borrow or backfill: a row you overwrote is a day that does not exist any more. If you find yourself writing an upsert in this codebase, you have deleted the product. Second, do not compute a number you do not have the data for. Competitor intelligence, keyword difficulty and revenue estimates are reads over a catalogue of millions of apps with months of accumulated history behind them. On day one you have your own apps and one day of observations. Those panels ship as empty states that say exactly what data they would need and how long you would have to collect it, because a plausible figure derived from a single day teaches you to trust something that is not a measurement of anything. The corollary for the screenshot half: an image model will happily draw star ratings, award badges and text that is nearly-but-not-quite words. Those are claims about your app on a store listing. Composite real text yourself and reject any render that invents a badge. ### Stack (fixed, do not substitute) - Runtime: Node 22 and TypeScript with a small local interface, one machine · this is a tool for your own apps, not a service - Storage: SQLite, insert only, with raw responses kept on disk · the history is the product and it has to be reconstructable - Collection: A scheduler with a rate limit read from the current published figure · the endpoints need no key, so throttling arrives as silence - Images: An image model for panel composition, real text composited afterwards · model-drawn glyphs are the tell and they fail at thumbnail size - Review: A vision model checking every render against an explicit list · you will not spot a sliced element in a six panel set by eye ### Data model (create this before Phase 1) - apps(track_id, storefront, name, bundle_id, added_at) · the apps you own, listed by hand - polls(id, at, endpoint, url, http_status, request_ms, response_sha256, raw_path) · one row per request, failures included - ratings(poll_id, track_id, storefront, average, count, app_version) · insert only, never reconciled - ranks(poll_id, track_id, storefront, chart, genre_id, position) · a missing position is a row with a null and a reason, not an absent row - keyword_positions(poll_id, keyword, storefront, track_id, position, results_returned) - reviews(id, track_id, storefront, review_id, review_at, first_seen_at, rating, title, body, app_version) - screenshot_runs(id, at, device, prompt_version, model_id, cost_minor, candidate_count, chosen_candidate) - screenshot_checks(candidate_id, check_name, passed, notes) · the critic's findings kept whether it passed or failed ### Phase 1 · A poller that behaves and keeps its receipts Build: A scheduler polling the public lookup, search, chart and review endpoints for your own apps. Send a real identifying user agent. Read the current published rate limit before choosing an interval and leave headroom under it. Write a poll row for every request with its status, duration and the path to the raw response body, and back off on anything that is not success. Trap: Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. Trap: Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. Done when: The scheduler never exceeds the configured rate, asserted with a controlled clock in a test. Every request writes a poll row including its status and the raw body path, failures included, verified by pointing it at a server that returns errors. A non-success backs off exponentially and the next attempt is recorded with the delay. A day with no successful poll renders as a gap in every chart, not as a flat line. Do not build yet: No charts, review diffing or screenshot work yet. ### Phase 2 · A time series nothing is allowed to overwrite Build: Store each observation against its poll id with no unique key that would tempt an upsert. Chart rating average, rating count, chart position and keyword position over time. Show the count of observation days prominently, and refuse to draw a trend line below a configured minimum number of days. Trap: Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. Trap: Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. Done when: Two polls for the same app on the same day produce two observation rows, asserted. No table in the tracking half has an update or upsert statement anywhere, verified by searching the source. Charts break across days with no successful poll and label the gap. A trend line is refused below the configured minimum day count and the interface says how many more days are needed. Do not build yet: No review diffing, screenshots or cost work yet. ### Phase 3 · New reviews since you last looked Build: Poll the public review feed per storefront, key each review on its own identifier, and store first_seen_at separately from the review's own date. Show what arrived since your last visit. Treat a changed body for an existing identifier as a new row rather than an edit in place. Trap: Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. Done when: Polling the same feed twice adds no duplicate reviews, tested against a fixture served twice. A review whose body changes produces a second row and both are visible in order, tested. first_seen_at is the poll time and review_at is the store's date, and the two are never conflated in the interface. A review that disappears from the feed stays in the database and is marked rather than deleted. Do not build yet: No screenshot pipeline or spending controls yet. ### Phase 4 · Panels, and a critic that rejects them Build: Pass your real app screens as reference images so the interface inside the phone frame stays faithful. Composite every headline as real text afterwards rather than letting the model draw glyphs. Send each render to a vision model with an explicit checklist and re-render once with the failures quoted back. Resize to the exact dimensions the store currently publishes for the display sizes you are submitting, read from the specification page on the day you build. Trap: An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. Trap: Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. Done when: Every candidate has check rows for each item on the list, stored regardless of outcome. A candidate failing any check is either re-rendered once with the failures quoted or held for review, never shipped silently. All headline text in the output is composited rather than generated, verified by producing a set with the text layer disabled. Output dimensions match the figures read from the store's current specification page, with the page URL and date recorded in the repository. Do not build yet: No cost ceiling, export or empty state work yet. ### Phase 5 · Spending, exports and honest empty states Build: Track spend per month against a ceiling and stop generating when it is reached, leaving the tracker running. Export every observation table as CSV and reimport it into an empty database. Ship the competitor, keyword difficulty and revenue panels as empty states naming the data they would need. Put the collection start date on the front page. Trap: A screenshot pipeline with two model calls per candidate and a re-render on failure costs more per iteration than you expect, and an experiment left running overnight is a bill. Count every call, hold the total against a monthly ceiling, and refuse to start a run that would cross it rather than stopping halfway through a set. Trap: Filling an empty competitor panel with something derived from your own single day of data is how a placeholder becomes a number somebody plans around. Ship the empty state with a sentence about what a catalogue of that size actually requires, and leave it empty until you have it. Done when: Reaching the monthly ceiling stops image generation with a clear message and leaves collection running, tested at the boundary. Export produces every observation table as CSV and reimports into an empty database with identical row counts, tested. Panels requiring a catalogue render as empty states with an explanation and contain no computed figure, verified by review. The front page shows the collection start date and the number of observation days. Do not build yet: Nothing after this. ### Out of scope (and why) - Competitor intelligence and keyword difficulty scores, which are reads over a catalogue of millions of apps across dozens of storefronts with months of history and cannot be approximated from your own apps. - Revenue and download estimates, which are calibrated against that same catalogue and are a model of other people's data, not a measurement of yours. - Tracking apps you do not own, which turns a polite personal poller into a crawler with a very different relationship to the endpoints it depends on. - Automated store submission, which needs the store's authenticated publishing interface and its own review process rather than a folder of images. ### README must contain - That every table in the tracking half is insert only and that an upsert would destroy the product - That the tracker is worth little until it has been collecting for a month, and what the front page day count means - Where the current rate limit figure came from, with the URL and the date it was read - That headlines are composited as real text and that every render is checked for invented ratings and badges - The two API keys, the cost per screenshot set measured rather than estimated, and the monthly ceiling ===== AGENTS.md ===== # Agent instructions · AppGrowKit indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 and TypeScript with a small local interface, one machine, SQLite, insert only, with raw responses kept on disk, A scheduler with a rate limit read from the current published figure, An image model for panel composition, real text composited afterwards, A vision model checking every render against an explicit list. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. ===== BUILD_PLAN.md ===== # Build plan · AppGrowKit A personal App Store tracker and screenshot generator whose hard problems are polling an unauthenticated public endpoint politely, keeping an insert-only time series that is worth something in six months, and catching what an image model invents before it reaches a store listing. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A poller that behaves and keeps its receipts Collection runs on a schedule, stays under the published rate, and records every request including the failures. ### Steps 1. Read the current published rate limit and set the interval Do not copy a number from a blog post, including this one. Files: `src/collect/schedule.ts`, `docs/rate-limit.md` 2. Write a poll row for every request The failures are what make a gap explainable. Files: `src/collect/poll.ts`, `tests/poll.test.ts` 3. Back off exponentially on any non-success Silence is what throttling looks like from here. Files: `src/collect/backoff.ts` ### Done when - [ ] The configured rate is never exceeded under test - [ ] Every request writes a poll row, including failures - [ ] Non-success backs off and the delay is recorded - [ ] A day with no successful poll renders as a gap ### Watch out - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. ## Phase 2 · A time series nothing is allowed to overwrite Ratings, ranks and keyword positions accumulate as insert-only observations tied to the poll that saw them. ### Steps 1. Insert observations, never reconcile them The chart is the product; the current value is a by-product. Files: `src/store/observations.ts`, `tests/insert-only.test.ts` 2. Break the line across missing days A tidy chart with invented points is worse than a gappy one. Files: `src/ui/charts.tsx` 3. Refuse a trend below a minimum day count Two points is not a trend however tempting the slope is. Files: `src/analysis/trend.ts` ### Done when - [ ] Two same-day polls yield two rows - [ ] No update or upsert exists in the tracking code - [ ] Charts break and label gaps - [ ] Trend lines are refused below the minimum day count ### Watch out - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. ## Phase 3 · New reviews since you last looked The review feed is diffed by review identity so nothing is double counted and nothing is missed. ### Steps 1. Key on the review identifier, never on position The feed reorders and pages without telling you. Files: `src/collect/reviews.ts`, `tests/reviews.test.ts` 2. Keep first seen separate from the review's own date One is your observation and one is theirs. Files: `src/store/reviews.ts` 3. Retain reviews that vanish from the feed Disappearing is information, not a reason to delete. Files: `src/collect/reviews.ts` ### Done when - [ ] Polling twice creates no duplicate reviews - [ ] An edited review produces a second row - [ ] First seen and review date are distinct in the interface - [ ] A vanished review is marked, not deleted ### Watch out - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. ## Phase 4 · Panels, and a critic that rejects them Screenshot sets are generated from your real screens and every candidate is checked before you see it. ### Steps 1. Pass the real screens as reference images Otherwise the phone in the panel runs an app you do not have. Files: `src/screens/generate.ts` 2. Composite headlines as real text after generation Model-drawn type is the thing reviewers notice first. Files: `src/screens/compose.ts` 3. Store every critic check, passed or failed The failures are how you improve the prompt. Files: `src/screens/critic.ts`, `tests/critic.test.ts` ### Done when - [ ] Every candidate has stored check rows - [ ] A failing candidate is re-rendered or held, never shipped - [ ] Headlines are composited, not generated - [ ] Dimensions match the currently published specification ### Watch out - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. ## Not in this build - Competitor intelligence and keyword difficulty. - Revenue and download estimates. - Tracking apps you do not own. - Automated store submission. ## After v1, if you want it - A weekly digest of what moved, built from the same observations rather than a second pipeline - Annotating the timeline with your own release dates, which is the only causal information you actually have - A second storefront for an app you already ship there, which tests the storefront dimension before it matters ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Apps, polls and every observation. This is the asset; back it up daily. DATABASE_PATH=./data/aso.db # Required. Raw bodies keyed by hash, so any observation can be re-derived. RAW_RESPONSE_DIR=./data/raw # Required. Derived from the currently published rate limit with headroom. Recheck it. POLL_INTERVAL_MS=3000 # Required. Identify yourself with a real contact address on every request. USER_AGENT=my-aso-tracker/1.0 (contact@example.com) # Required · secret. Panel generation. Also set a provider-side spend limit. IMAGE_API_KEY=sk-...redacted # Required · secret. The critic pass. May be the same provider as generation. VISION_API_KEY=sk-...redacted # Required. Generation stops here for the month; collection keeps running. MONTHLY_COST_CEILING=20.00 # Required. Below this, the interface shows points and refuses to draw a trend. MIN_TREND_DAYS=14
You are building a production product version of AppGrowKit. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # AppGrowKit · product brief ## Problem Split the product in half and the answer changes. The screenshot generator is genuinely one-shottable: it is an image model behind a prompt, and a weekend of iteration gets you panels good enough to ship. The tracking half is too, if you only care about apps you own, because Apple's public endpoints hand you ratings, reviews, and chart positions for free. What you cannot build is the part you would be paying for: a catalog of 1.7 million apps across 36 storefronts, with 22 million rating observations and 11 million rank observations accumulated over months. 1.25 million of those apps have more than one day of history and 960,000 have ten days or more, which is the difference between a table of apps and a time series. You can start collecting today, and in six months you will have six months of it. Competitor intelligence, keyword difficulty, and revenue estimates are all reads over that history, so they arrive empty on day one and stay thin for a season. Build it if you track a handful of your own apps. Pay if you need to answer questions about apps you have never opened. ## Product outcome A tracker whose history is insert only and therefore worth more every week, a poller that stays inside the published limit and records its failures, and a screenshot pipeline that rejects renders inventing claims about your app. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - image model API key (fal.ai or OpenAI) - vision model API key for the QA pass - SQLite or Postgres - a scheduler that actually runs daily ## Explicit non-goals for v1 - Competitor intelligence and keyword difficulty. - Revenue and download estimates. - Tracking apps you do not own. - Automated store submission. - months of rank, rating, and keyword history you cannot backfill - the 1.7M-app, 36-storefront catalog behind competitor lookups and keyword difficulty - revenue and download estimates, which are calibrated against that catalog - keyword volume and competition scores, which need a corpus to be relative to - the MCP server that answers ASO questions from Claude, Cursor, or ChatGPT - an accuracy pass on generated screenshots: the QA critic, best-of-2 scoring, and exact-size output ## Success criteria - No update or upsert is confirmed present in the tracking code - The configured rate limit is confirmed never exceeded under test - Failed polls are confirmed recorded and rendered as gaps - Headlines are confirmed composited rather than model drawn - Catalogue-dependent panels are confirmed to contain no computed figure ===== BRIEF.md ===== # Build brief · AppGrowKit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an App Store screenshot generator and a rank tracker for apps you own to replace AppGrowKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the entire reason this build is worth starting today. Nothing in the tracking half of this app is ever updated. Every poll is an insert carrying its timestamp, the exact URL requested, the HTTP status and the raw response, including the polls that failed. Rank and rating history is the only asset here you cannot buy, borrow or backfill: a row you overwrote is a day that does not exist any more. If you find yourself writing an upsert in this codebase, you have deleted the product. Second, do not compute a number you do not have the data for. Competitor intelligence, keyword difficulty and revenue estimates are reads over a catalogue of millions of apps with months of accumulated history behind them. On day one you have your own apps and one day of observations. Those panels ship as empty states that say exactly what data they would need and how long you would have to collect it, because a plausible figure derived from a single day teaches you to trust something that is not a measurement of anything. The corollary for the screenshot half: an image model will happily draw star ratings, award badges and text that is nearly-but-not-quite words. Those are claims about your app on a store listing. Composite real text yourself and reject any render that invents a badge. ### Stack (fixed, do not substitute) - Runtime: Node 22 and TypeScript with a small local interface, one machine · this is a tool for your own apps, not a service - Storage: SQLite, insert only, with raw responses kept on disk · the history is the product and it has to be reconstructable - Collection: A scheduler with a rate limit read from the current published figure · the endpoints need no key, so throttling arrives as silence - Images: An image model for panel composition, real text composited afterwards · model-drawn glyphs are the tell and they fail at thumbnail size - Review: A vision model checking every render against an explicit list · you will not spot a sliced element in a six panel set by eye ### Data model (create this before Phase 1) - apps(track_id, storefront, name, bundle_id, added_at) · the apps you own, listed by hand - polls(id, at, endpoint, url, http_status, request_ms, response_sha256, raw_path) · one row per request, failures included - ratings(poll_id, track_id, storefront, average, count, app_version) · insert only, never reconciled - ranks(poll_id, track_id, storefront, chart, genre_id, position) · a missing position is a row with a null and a reason, not an absent row - keyword_positions(poll_id, keyword, storefront, track_id, position, results_returned) - reviews(id, track_id, storefront, review_id, review_at, first_seen_at, rating, title, body, app_version) - screenshot_runs(id, at, device, prompt_version, model_id, cost_minor, candidate_count, chosen_candidate) - screenshot_checks(candidate_id, check_name, passed, notes) · the critic's findings kept whether it passed or failed ### Phase 1 · A poller that behaves and keeps its receipts Build: A scheduler polling the public lookup, search, chart and review endpoints for your own apps. Send a real identifying user agent. Read the current published rate limit before choosing an interval and leave headroom under it. Write a poll row for every request with its status, duration and the path to the raw response body, and back off on anything that is not success. Trap: Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. Trap: Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. Done when: The scheduler never exceeds the configured rate, asserted with a controlled clock in a test. Every request writes a poll row including its status and the raw body path, failures included, verified by pointing it at a server that returns errors. A non-success backs off exponentially and the next attempt is recorded with the delay. A day with no successful poll renders as a gap in every chart, not as a flat line. Do not build yet: No charts, review diffing or screenshot work yet. ### Phase 2 · A time series nothing is allowed to overwrite Build: Store each observation against its poll id with no unique key that would tempt an upsert. Chart rating average, rating count, chart position and keyword position over time. Show the count of observation days prominently, and refuse to draw a trend line below a configured minimum number of days. Trap: Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. Trap: Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. Done when: Two polls for the same app on the same day produce two observation rows, asserted. No table in the tracking half has an update or upsert statement anywhere, verified by searching the source. Charts break across days with no successful poll and label the gap. A trend line is refused below the configured minimum day count and the interface says how many more days are needed. Do not build yet: No review diffing, screenshots or cost work yet. ### Phase 3 · New reviews since you last looked Build: Poll the public review feed per storefront, key each review on its own identifier, and store first_seen_at separately from the review's own date. Show what arrived since your last visit. Treat a changed body for an existing identifier as a new row rather than an edit in place. Trap: Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. Done when: Polling the same feed twice adds no duplicate reviews, tested against a fixture served twice. A review whose body changes produces a second row and both are visible in order, tested. first_seen_at is the poll time and review_at is the store's date, and the two are never conflated in the interface. A review that disappears from the feed stays in the database and is marked rather than deleted. Do not build yet: No screenshot pipeline or spending controls yet. ### Phase 4 · Panels, and a critic that rejects them Build: Pass your real app screens as reference images so the interface inside the phone frame stays faithful. Composite every headline as real text afterwards rather than letting the model draw glyphs. Send each render to a vision model with an explicit checklist and re-render once with the failures quoted back. Resize to the exact dimensions the store currently publishes for the display sizes you are submitting, read from the specification page on the day you build. Trap: An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. Trap: Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. Done when: Every candidate has check rows for each item on the list, stored regardless of outcome. A candidate failing any check is either re-rendered once with the failures quoted or held for review, never shipped silently. All headline text in the output is composited rather than generated, verified by producing a set with the text layer disabled. Output dimensions match the figures read from the store's current specification page, with the page URL and date recorded in the repository. Do not build yet: No cost ceiling, export or empty state work yet. ### Phase 5 · Spending, exports and honest empty states Build: Track spend per month against a ceiling and stop generating when it is reached, leaving the tracker running. Export every observation table as CSV and reimport it into an empty database. Ship the competitor, keyword difficulty and revenue panels as empty states naming the data they would need. Put the collection start date on the front page. Trap: A screenshot pipeline with two model calls per candidate and a re-render on failure costs more per iteration than you expect, and an experiment left running overnight is a bill. Count every call, hold the total against a monthly ceiling, and refuse to start a run that would cross it rather than stopping halfway through a set. Trap: Filling an empty competitor panel with something derived from your own single day of data is how a placeholder becomes a number somebody plans around. Ship the empty state with a sentence about what a catalogue of that size actually requires, and leave it empty until you have it. Done when: Reaching the monthly ceiling stops image generation with a clear message and leaves collection running, tested at the boundary. Export produces every observation table as CSV and reimports into an empty database with identical row counts, tested. Panels requiring a catalogue render as empty states with an explanation and contain no computed figure, verified by review. The front page shows the collection start date and the number of observation days. Do not build yet: Nothing after this. ### Out of scope (and why) - Competitor intelligence and keyword difficulty scores, which are reads over a catalogue of millions of apps across dozens of storefronts with months of history and cannot be approximated from your own apps. - Revenue and download estimates, which are calibrated against that same catalogue and are a model of other people's data, not a measurement of yours. - Tracking apps you do not own, which turns a polite personal poller into a crawler with a very different relationship to the endpoints it depends on. - Automated store submission, which needs the store's authenticated publishing interface and its own review process rather than a folder of images. ### README must contain - That every table in the tracking half is insert only and that an upsert would destroy the product - That the tracker is worth little until it has been collecting for a month, and what the front page day count means - Where the current rate limit figure came from, with the URL and the date it was read - That headlines are composited as real text and that every render is checked for invented ratings and badges - The two API keys, the cost per screenshot set measured rather than estimated, and the monthly ceiling ===== ARCHITECTURE.md ===== # Architecture · AppGrowKit ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 and TypeScript with a small local interface, one machine | this is a tool for your own apps, not a service | | Storage | SQLite, insert only, with raw responses kept on disk | the history is the product and it has to be reconstructable | | Collection | A scheduler with a rate limit read from the current published figure | the endpoints need no key, so throttling arrives as silence | | Images | An image model for panel composition, real text composited afterwards | model-drawn glyphs are the tell and they fail at thumbnail size | | Review | A vision model checking every render against an explicit list | you will not spot a sliced element in a six panel set by eye | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Collector | Scheduling, rate limiting, back-off and poll rows. | Any transport, provided every attempt is recorded. | | Store | Insert-only observation tables and raw bodies. | Any database, provided nothing acquires an upsert. | | Reviews | Identity-keyed diffing and first-seen tracking. | Any feed, provided position is never the key. | | Generator | Reference images, panel composition, real text. | Any image model, provided headlines stay composited. | | Critic | The checklist, the re-render and the stored results. | Any vision model, provided failures are stored not discarded. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Apps, polls and every observation. This is the asset; back it up daily. - `RAW_RESPONSE_DIR` · required · Raw bodies keyed by hash, so any observation can be re-derived. - `POLL_INTERVAL_MS` · required · Derived from the currently published rate limit with headroom. Recheck it. - `USER_AGENT` · required · Identify yourself with a real contact address on every request. - `IMAGE_API_KEY` · required, secret · Panel generation. Also set a provider-side spend limit. - `VISION_API_KEY` · required, secret · The critic pass. May be the same provider as generation. - `MONTHLY_COST_CEILING` · required · Generation stops here for the month; collection keeps running. - `MIN_TREND_DAYS` · required · Below this, the interface shows points and refuses to draw a trend. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · AppGrowKit product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 and TypeScript with a small local interface, one machine, SQLite, insert only, with raw responses kept on disk, A scheduler with a rate limit read from the current published figure, An image model for panel composition, real text composited afterwards, A vision model checking every render against an explicit list. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. - A screenshot pipeline with two model calls per candidate and a re-render on failure costs more per iteration than you expect, and an experiment left running overnight is a bill. Count every call, hold the total against a monthly ceiling, and refuse to start a run that would cross it rather than stopping halfway through a set. - Filling an empty competitor panel with something derived from your own single day of data is how a placeholder becomes a number somebody plans around. Ship the empty state with a sentence about what a catalogue of that size actually requires, and leave it empty until you have it. ===== MILESTONES.md ===== # Delivery milestones · AppGrowKit Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A poller that behaves and keeps its receipts Collection runs on a schedule, stays under the published rate, and records every request including the failures. ### Steps 1. Read the current published rate limit and set the interval Do not copy a number from a blog post, including this one. Files: `src/collect/schedule.ts`, `docs/rate-limit.md` 2. Write a poll row for every request The failures are what make a gap explainable. Files: `src/collect/poll.ts`, `tests/poll.test.ts` 3. Back off exponentially on any non-success Silence is what throttling looks like from here. Files: `src/collect/backoff.ts` ### Done when - [ ] The configured rate is never exceeded under test - [ ] Every request writes a poll row, including failures - [ ] Non-success backs off and the delay is recorded - [ ] A day with no successful poll renders as a gap ### Watch out - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. ## M2 · A time series nothing is allowed to overwrite Ratings, ranks and keyword positions accumulate as insert-only observations tied to the poll that saw them. ### Steps 1. Insert observations, never reconcile them The chart is the product; the current value is a by-product. Files: `src/store/observations.ts`, `tests/insert-only.test.ts` 2. Break the line across missing days A tidy chart with invented points is worse than a gappy one. Files: `src/ui/charts.tsx` 3. Refuse a trend below a minimum day count Two points is not a trend however tempting the slope is. Files: `src/analysis/trend.ts` ### Done when - [ ] Two same-day polls yield two rows - [ ] No update or upsert exists in the tracking code - [ ] Charts break and label gaps - [ ] Trend lines are refused below the minimum day count ### Watch out - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. ## M3 · New reviews since you last looked The review feed is diffed by review identity so nothing is double counted and nothing is missed. ### Steps 1. Key on the review identifier, never on position The feed reorders and pages without telling you. Files: `src/collect/reviews.ts`, `tests/reviews.test.ts` 2. Keep first seen separate from the review's own date One is your observation and one is theirs. Files: `src/store/reviews.ts` 3. Retain reviews that vanish from the feed Disappearing is information, not a reason to delete. Files: `src/collect/reviews.ts` ### Done when - [ ] Polling twice creates no duplicate reviews - [ ] An edited review produces a second row - [ ] First seen and review date are distinct in the interface - [ ] A vanished review is marked, not deleted ### Watch out - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. ## M4 · Panels, and a critic that rejects them Screenshot sets are generated from your real screens and every candidate is checked before you see it. ### Steps 1. Pass the real screens as reference images Otherwise the phone in the panel runs an app you do not have. Files: `src/screens/generate.ts` 2. Composite headlines as real text after generation Model-drawn type is the thing reviewers notice first. Files: `src/screens/compose.ts` 3. Store every critic check, passed or failed The failures are how you improve the prompt. Files: `src/screens/critic.ts`, `tests/critic.test.ts` ### Done when - [ ] Every candidate has stored check rows - [ ] A failing candidate is re-rendered or held, never shipped - [ ] Headlines are composited, not generated - [ ] Dimensions match the currently published specification ### Watch out - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. ## M5 · Spending, exports and honest empty states (production only) Image generation has a ceiling, the history exports, and the panels you cannot fill say why. ### Steps 1. Refuse a run that would cross the ceiling Stopping halfway through a set wastes what you spent. Files: `src/cost/ceiling.ts`, `tests/cost.test.ts` 2. Round trip the whole history through CSV The history is the asset, so it has to be portable. Files: `src/export/csv.ts` 3. Ship the impossible panels as empty states An honest blank beats a plausible invention. Files: `src/ui/empty-states.tsx` ### Done when - [ ] The monthly ceiling stops generation, not collection - [ ] Export and reimport preserve row counts - [ ] Catalogue panels contain no computed figure - [ ] Collection start date and day count are on the front page ### Watch out - A screenshot pipeline with two model calls per candidate and a re-render on failure costs more per iteration than you expect, and an experiment left running overnight is a bill. Count every call, hold the total against a monthly ceiling, and refuse to start a run that would cross it rather than stopping halfway through a set. - Filling an empty competitor panel with something derived from your own single day of data is how a placeholder becomes a number somebody plans around. Ship the empty state with a sentence about what a catalogue of that size actually requires, and leave it empty until you have it. ===== OPERATIONS.md ===== # Operations · AppGrowKit ## Backup Daily, and this one genuinely matters: the database holds observations that cannot be re-collected, unlike almost everything else you will ever back up. Keep the raw response directory too, because it lets you rebuild a derived table after fixing a parsing bug. ## Restore Restore both the database and the raw directory, confirm the observation day count matches the last recorded figure, and re-derive one table from raw bodies to prove the pair is consistent. A restore that leaves a hole in the history is a permanent hole. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Polls attempted against polls succeeded per day, the longest gap in the last month, spend against the ceiling, and the proportion of screenshot candidates failing the critic. A rising failure rate on polls usually means throttling rather than an outage. ## Incident checklist The incident is a silent collection failure discovered weeks later, which is why the poll table records failures and the front page shows the day count. There is no recovery for a missed day, so the monitoring is the mitigation. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No update or upsert is confirmed present in the tracking code - [ ] The configured rate limit is confirmed never exceeded under test - [ ] Failed polls are confirmed recorded and rendered as gaps - [ ] Headlines are confirmed composited rather than model drawn - [ ] Catalogue-dependent panels are confirmed to contain no computed figure ## Launch constraint Do not market omitted AppGrowKit capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Apps, polls and every observation. This is the asset; back it up daily. DATABASE_PATH=./data/aso.db # Required. Raw bodies keyed by hash, so any observation can be re-derived. RAW_RESPONSE_DIR=./data/raw # Required. Derived from the currently published rate limit with headroom. Recheck it. POLL_INTERVAL_MS=3000 # Required. Identify yourself with a real contact address on every request. USER_AGENT=my-aso-tracker/1.0 (contact@example.com) # Required · secret. Panel generation. Also set a provider-side spend limit. IMAGE_API_KEY=sk-...redacted # Required · secret. The critic pass. May be the same provider as generation. VISION_API_KEY=sk-...redacted # Required. Generation stops here for the month; collection keeps running. MONTHLY_COST_CEILING=20.00 # Required. Below this, the interface shows points and refuses to draw a trend. MIN_TREND_DAYS=14
# AppGrowKit · indie build A personal App Store tracker and screenshot generator whose hard problems are polling an unauthenticated public endpoint politely, keeping an insert-only time series that is worth something in six months, and catching what an image model invents before it reaches a store listing. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 and TypeScript with a small local interface, one machine | this is a tool for your own apps, not a service | | Storage | SQLite, insert only, with raw responses kept on disk | the history is the product and it has to be reconstructable | | Collection | A scheduler with a rate limit read from the current published figure | the endpoints need no key, so throttling arrives as silence | | Images | An image model for panel composition, real text composited afterwards | model-drawn glyphs are the tell and they fail at thumbnail size | | Review | A vision model checking every render against an explicit list | you will not spot a sliced element in a six panel set by eye | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node 22 or newer with TypeScript** · free - Why: Runs the collector, the interface and the screenshot pipeline. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A scheduler that runs whether or not you are logged in** · free - Why: A rank tracker that only polls when you happen to have a terminal open produces a history full of holes, which is the one thing it cannot recover from. - Get it: A system timer or service on an always-on machine, not a terminal loop. - Verify: A poll row exists for every scheduled slot over three consecutive days - [ ] **An API key for an image model** · pay per image; measure your own cost per set on the first run - Why: Panel composition is the only part of the build that costs money per use, which is why it has a ceiling. - Get it: Create a key with your chosen provider and set a spend limit in their console as well as in this app. - Verify: One panel renders and a cost row is written - [ ] **An API key for a vision model** · pay per image, smaller than generation - Why: The critic pass is what stops an invented award badge reaching a store listing, and it needs a model that can read a rendered image. - Get it: A multimodal model from any provider, which may be the same key as the image model or a different one. - Verify: A deliberately garbled render fails at least one check - [ ] **The store's current screenshot specification page** · free - Why: Required dimensions and device classes change, and a set built to last year's numbers is rejected at submission. - Get it: Open the store's published specification on the day you build, record the URL, the date and the figures in the repository. - Verify: The recorded figures match what the exporter produces ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Competitor intelligence and keyword difficulty. - Revenue and download estimates. - Tracking apps you do not own. - Automated store submission. - months of rank, rating, and keyword history you cannot backfill - the 1.7M-app, 36-storefront catalog behind competitor lookups and keyword difficulty - revenue and download estimates, which are calibrated against that catalog - keyword volume and competition scores, which need a corpus to be relative to - the MCP server that answers ASO questions from Claude, Cursor, or ChatGPT - an accuracy pass on generated screenshots: the QA critic, best-of-2 scoring, and exact-size output If one of those is essential to you, that is the reason to keep paying for AppGrowKit, and the README should say so rather than pretend.
# Build brief · AppGrowKit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an App Store screenshot generator and a rank tracker for apps you own to replace AppGrowKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the entire reason this build is worth starting today. Nothing in the tracking half of this app is ever updated. Every poll is an insert carrying its timestamp, the exact URL requested, the HTTP status and the raw response, including the polls that failed. Rank and rating history is the only asset here you cannot buy, borrow or backfill: a row you overwrote is a day that does not exist any more. If you find yourself writing an upsert in this codebase, you have deleted the product. Second, do not compute a number you do not have the data for. Competitor intelligence, keyword difficulty and revenue estimates are reads over a catalogue of millions of apps with months of accumulated history behind them. On day one you have your own apps and one day of observations. Those panels ship as empty states that say exactly what data they would need and how long you would have to collect it, because a plausible figure derived from a single day teaches you to trust something that is not a measurement of anything. The corollary for the screenshot half: an image model will happily draw star ratings, award badges and text that is nearly-but-not-quite words. Those are claims about your app on a store listing. Composite real text yourself and reject any render that invents a badge. ### Stack (fixed, do not substitute) - Runtime: Node 22 and TypeScript with a small local interface, one machine · this is a tool for your own apps, not a service - Storage: SQLite, insert only, with raw responses kept on disk · the history is the product and it has to be reconstructable - Collection: A scheduler with a rate limit read from the current published figure · the endpoints need no key, so throttling arrives as silence - Images: An image model for panel composition, real text composited afterwards · model-drawn glyphs are the tell and they fail at thumbnail size - Review: A vision model checking every render against an explicit list · you will not spot a sliced element in a six panel set by eye ### Data model (create this before Phase 1) - apps(track_id, storefront, name, bundle_id, added_at) · the apps you own, listed by hand - polls(id, at, endpoint, url, http_status, request_ms, response_sha256, raw_path) · one row per request, failures included - ratings(poll_id, track_id, storefront, average, count, app_version) · insert only, never reconciled - ranks(poll_id, track_id, storefront, chart, genre_id, position) · a missing position is a row with a null and a reason, not an absent row - keyword_positions(poll_id, keyword, storefront, track_id, position, results_returned) - reviews(id, track_id, storefront, review_id, review_at, first_seen_at, rating, title, body, app_version) - screenshot_runs(id, at, device, prompt_version, model_id, cost_minor, candidate_count, chosen_candidate) - screenshot_checks(candidate_id, check_name, passed, notes) · the critic's findings kept whether it passed or failed ### Phase 1 · A poller that behaves and keeps its receipts Build: A scheduler polling the public lookup, search, chart and review endpoints for your own apps. Send a real identifying user agent. Read the current published rate limit before choosing an interval and leave headroom under it. Write a poll row for every request with its status, duration and the path to the raw response body, and back off on anything that is not success. Trap: Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. Trap: Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. Done when: The scheduler never exceeds the configured rate, asserted with a controlled clock in a test. Every request writes a poll row including its status and the raw body path, failures included, verified by pointing it at a server that returns errors. A non-success backs off exponentially and the next attempt is recorded with the delay. A day with no successful poll renders as a gap in every chart, not as a flat line. Do not build yet: No charts, review diffing or screenshot work yet. ### Phase 2 · A time series nothing is allowed to overwrite Build: Store each observation against its poll id with no unique key that would tempt an upsert. Chart rating average, rating count, chart position and keyword position over time. Show the count of observation days prominently, and refuse to draw a trend line below a configured minimum number of days. Trap: Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. Trap: Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. Done when: Two polls for the same app on the same day produce two observation rows, asserted. No table in the tracking half has an update or upsert statement anywhere, verified by searching the source. Charts break across days with no successful poll and label the gap. A trend line is refused below the configured minimum day count and the interface says how many more days are needed. Do not build yet: No review diffing, screenshots or cost work yet. ### Phase 3 · New reviews since you last looked Build: Poll the public review feed per storefront, key each review on its own identifier, and store first_seen_at separately from the review's own date. Show what arrived since your last visit. Treat a changed body for an existing identifier as a new row rather than an edit in place. Trap: Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. Done when: Polling the same feed twice adds no duplicate reviews, tested against a fixture served twice. A review whose body changes produces a second row and both are visible in order, tested. first_seen_at is the poll time and review_at is the store's date, and the two are never conflated in the interface. A review that disappears from the feed stays in the database and is marked rather than deleted. Do not build yet: No screenshot pipeline or spending controls yet. ### Phase 4 · Panels, and a critic that rejects them Build: Pass your real app screens as reference images so the interface inside the phone frame stays faithful. Composite every headline as real text afterwards rather than letting the model draw glyphs. Send each render to a vision model with an explicit checklist and re-render once with the failures quoted back. Resize to the exact dimensions the store currently publishes for the display sizes you are submitting, read from the specification page on the day you build. Trap: An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. Trap: Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. Done when: Every candidate has check rows for each item on the list, stored regardless of outcome. A candidate failing any check is either re-rendered once with the failures quoted or held for review, never shipped silently. All headline text in the output is composited rather than generated, verified by producing a set with the text layer disabled. Output dimensions match the figures read from the store's current specification page, with the page URL and date recorded in the repository. Do not build yet: No cost ceiling, export or empty state work yet. ### Phase 5 · Spending, exports and honest empty states Build: Track spend per month against a ceiling and stop generating when it is reached, leaving the tracker running. Export every observation table as CSV and reimport it into an empty database. Ship the competitor, keyword difficulty and revenue panels as empty states naming the data they would need. Put the collection start date on the front page. Trap: A screenshot pipeline with two model calls per candidate and a re-render on failure costs more per iteration than you expect, and an experiment left running overnight is a bill. Count every call, hold the total against a monthly ceiling, and refuse to start a run that would cross it rather than stopping halfway through a set. Trap: Filling an empty competitor panel with something derived from your own single day of data is how a placeholder becomes a number somebody plans around. Ship the empty state with a sentence about what a catalogue of that size actually requires, and leave it empty until you have it. Done when: Reaching the monthly ceiling stops image generation with a clear message and leaves collection running, tested at the boundary. Export produces every observation table as CSV and reimports into an empty database with identical row counts, tested. Panels requiring a catalogue render as empty states with an explanation and contain no computed figure, verified by review. The front page shows the collection start date and the number of observation days. Do not build yet: Nothing after this. ### Out of scope (and why) - Competitor intelligence and keyword difficulty scores, which are reads over a catalogue of millions of apps across dozens of storefronts with months of history and cannot be approximated from your own apps. - Revenue and download estimates, which are calibrated against that same catalogue and are a model of other people's data, not a measurement of yours. - Tracking apps you do not own, which turns a polite personal poller into a crawler with a very different relationship to the endpoints it depends on. - Automated store submission, which needs the store's authenticated publishing interface and its own review process rather than a folder of images. ### README must contain - That every table in the tracking half is insert only and that an upsert would destroy the product - That the tracker is worth little until it has been collecting for a month, and what the front page day count means - Where the current rate limit figure came from, with the URL and the date it was read - That headlines are composited as real text and that every render is checked for invented ratings and badges - The two API keys, the cost per screenshot set measured rather than estimated, and the monthly ceiling
# Agent instructions · AppGrowKit indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 and TypeScript with a small local interface, one machine, SQLite, insert only, with raw responses kept on disk, A scheduler with a rate limit read from the current published figure, An image model for panel composition, real text composited afterwards, A vision model checking every render against an explicit list. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free.
# Build plan · AppGrowKit A personal App Store tracker and screenshot generator whose hard problems are polling an unauthenticated public endpoint politely, keeping an insert-only time series that is worth something in six months, and catching what an image model invents before it reaches a store listing. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A poller that behaves and keeps its receipts Collection runs on a schedule, stays under the published rate, and records every request including the failures. ### Steps 1. Read the current published rate limit and set the interval Do not copy a number from a blog post, including this one. Files: `src/collect/schedule.ts`, `docs/rate-limit.md` 2. Write a poll row for every request The failures are what make a gap explainable. Files: `src/collect/poll.ts`, `tests/poll.test.ts` 3. Back off exponentially on any non-success Silence is what throttling looks like from here. Files: `src/collect/backoff.ts` ### Done when - [ ] The configured rate is never exceeded under test - [ ] Every request writes a poll row, including failures - [ ] Non-success backs off and the delay is recorded - [ ] A day with no successful poll renders as a gap ### Watch out - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. ## Phase 2 · A time series nothing is allowed to overwrite Ratings, ranks and keyword positions accumulate as insert-only observations tied to the poll that saw them. ### Steps 1. Insert observations, never reconcile them The chart is the product; the current value is a by-product. Files: `src/store/observations.ts`, `tests/insert-only.test.ts` 2. Break the line across missing days A tidy chart with invented points is worse than a gappy one. Files: `src/ui/charts.tsx` 3. Refuse a trend below a minimum day count Two points is not a trend however tempting the slope is. Files: `src/analysis/trend.ts` ### Done when - [ ] Two same-day polls yield two rows - [ ] No update or upsert exists in the tracking code - [ ] Charts break and label gaps - [ ] Trend lines are refused below the minimum day count ### Watch out - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. ## Phase 3 · New reviews since you last looked The review feed is diffed by review identity so nothing is double counted and nothing is missed. ### Steps 1. Key on the review identifier, never on position The feed reorders and pages without telling you. Files: `src/collect/reviews.ts`, `tests/reviews.test.ts` 2. Keep first seen separate from the review's own date One is your observation and one is theirs. Files: `src/store/reviews.ts` 3. Retain reviews that vanish from the feed Disappearing is information, not a reason to delete. Files: `src/collect/reviews.ts` ### Done when - [ ] Polling twice creates no duplicate reviews - [ ] An edited review produces a second row - [ ] First seen and review date are distinct in the interface - [ ] A vanished review is marked, not deleted ### Watch out - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. ## Phase 4 · Panels, and a critic that rejects them Screenshot sets are generated from your real screens and every candidate is checked before you see it. ### Steps 1. Pass the real screens as reference images Otherwise the phone in the panel runs an app you do not have. Files: `src/screens/generate.ts` 2. Composite headlines as real text after generation Model-drawn type is the thing reviewers notice first. Files: `src/screens/compose.ts` 3. Store every critic check, passed or failed The failures are how you improve the prompt. Files: `src/screens/critic.ts`, `tests/critic.test.ts` ### Done when - [ ] Every candidate has stored check rows - [ ] A failing candidate is re-rendered or held, never shipped - [ ] Headlines are composited, not generated - [ ] Dimensions match the currently published specification ### Watch out - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. ## Not in this build - Competitor intelligence and keyword difficulty. - Revenue and download estimates. - Tracking apps you do not own. - Automated store submission. ## After v1, if you want it - A weekly digest of what moved, built from the same observations rather than a second pipeline - Annotating the timeline with your own release dates, which is the only causal information you actually have - A second storefront for an app you already ship there, which tests the storefront dimension before it matters
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Apps, polls and every observation. This is the asset; back it up daily. DATABASE_PATH=./data/aso.db # Required. Raw bodies keyed by hash, so any observation can be re-derived. RAW_RESPONSE_DIR=./data/raw # Required. Derived from the currently published rate limit with headroom. Recheck it. POLL_INTERVAL_MS=3000 # Required. Identify yourself with a real contact address on every request. USER_AGENT=my-aso-tracker/1.0 (contact@example.com) # Required · secret. Panel generation. Also set a provider-side spend limit. IMAGE_API_KEY=sk-...redacted # Required · secret. The critic pass. May be the same provider as generation. VISION_API_KEY=sk-...redacted # Required. Generation stops here for the month; collection keeps running. MONTHLY_COST_CEILING=20.00 # Required. Below this, the interface shows points and refuses to draw a trend. MIN_TREND_DAYS=14
# AppGrowKit · product brief ## Problem Split the product in half and the answer changes. The screenshot generator is genuinely one-shottable: it is an image model behind a prompt, and a weekend of iteration gets you panels good enough to ship. The tracking half is too, if you only care about apps you own, because Apple's public endpoints hand you ratings, reviews, and chart positions for free. What you cannot build is the part you would be paying for: a catalog of 1.7 million apps across 36 storefronts, with 22 million rating observations and 11 million rank observations accumulated over months. 1.25 million of those apps have more than one day of history and 960,000 have ten days or more, which is the difference between a table of apps and a time series. You can start collecting today, and in six months you will have six months of it. Competitor intelligence, keyword difficulty, and revenue estimates are all reads over that history, so they arrive empty on day one and stay thin for a season. Build it if you track a handful of your own apps. Pay if you need to answer questions about apps you have never opened. ## Product outcome A tracker whose history is insert only and therefore worth more every week, a poller that stays inside the published limit and records its failures, and a screenshot pipeline that rejects renders inventing claims about your app. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - image model API key (fal.ai or OpenAI) - vision model API key for the QA pass - SQLite or Postgres - a scheduler that actually runs daily ## Explicit non-goals for v1 - Competitor intelligence and keyword difficulty. - Revenue and download estimates. - Tracking apps you do not own. - Automated store submission. - months of rank, rating, and keyword history you cannot backfill - the 1.7M-app, 36-storefront catalog behind competitor lookups and keyword difficulty - revenue and download estimates, which are calibrated against that catalog - keyword volume and competition scores, which need a corpus to be relative to - the MCP server that answers ASO questions from Claude, Cursor, or ChatGPT - an accuracy pass on generated screenshots: the QA critic, best-of-2 scoring, and exact-size output ## Success criteria - No update or upsert is confirmed present in the tracking code - The configured rate limit is confirmed never exceeded under test - Failed polls are confirmed recorded and rendered as gaps - Headlines are confirmed composited rather than model drawn - Catalogue-dependent panels are confirmed to contain no computed figure
# Build brief · AppGrowKit The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an App Store screenshot generator and a rank tracker for apps you own to replace AppGrowKit. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the entire reason this build is worth starting today. Nothing in the tracking half of this app is ever updated. Every poll is an insert carrying its timestamp, the exact URL requested, the HTTP status and the raw response, including the polls that failed. Rank and rating history is the only asset here you cannot buy, borrow or backfill: a row you overwrote is a day that does not exist any more. If you find yourself writing an upsert in this codebase, you have deleted the product. Second, do not compute a number you do not have the data for. Competitor intelligence, keyword difficulty and revenue estimates are reads over a catalogue of millions of apps with months of accumulated history behind them. On day one you have your own apps and one day of observations. Those panels ship as empty states that say exactly what data they would need and how long you would have to collect it, because a plausible figure derived from a single day teaches you to trust something that is not a measurement of anything. The corollary for the screenshot half: an image model will happily draw star ratings, award badges and text that is nearly-but-not-quite words. Those are claims about your app on a store listing. Composite real text yourself and reject any render that invents a badge. ### Stack (fixed, do not substitute) - Runtime: Node 22 and TypeScript with a small local interface, one machine · this is a tool for your own apps, not a service - Storage: SQLite, insert only, with raw responses kept on disk · the history is the product and it has to be reconstructable - Collection: A scheduler with a rate limit read from the current published figure · the endpoints need no key, so throttling arrives as silence - Images: An image model for panel composition, real text composited afterwards · model-drawn glyphs are the tell and they fail at thumbnail size - Review: A vision model checking every render against an explicit list · you will not spot a sliced element in a six panel set by eye ### Data model (create this before Phase 1) - apps(track_id, storefront, name, bundle_id, added_at) · the apps you own, listed by hand - polls(id, at, endpoint, url, http_status, request_ms, response_sha256, raw_path) · one row per request, failures included - ratings(poll_id, track_id, storefront, average, count, app_version) · insert only, never reconciled - ranks(poll_id, track_id, storefront, chart, genre_id, position) · a missing position is a row with a null and a reason, not an absent row - keyword_positions(poll_id, keyword, storefront, track_id, position, results_returned) - reviews(id, track_id, storefront, review_id, review_at, first_seen_at, rating, title, body, app_version) - screenshot_runs(id, at, device, prompt_version, model_id, cost_minor, candidate_count, chosen_candidate) - screenshot_checks(candidate_id, check_name, passed, notes) · the critic's findings kept whether it passed or failed ### Phase 1 · A poller that behaves and keeps its receipts Build: A scheduler polling the public lookup, search, chart and review endpoints for your own apps. Send a real identifying user agent. Read the current published rate limit before choosing an interval and leave headroom under it. Write a poll row for every request with its status, duration and the path to the raw response body, and back off on anything that is not success. Trap: Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. Trap: Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. Done when: The scheduler never exceeds the configured rate, asserted with a controlled clock in a test. Every request writes a poll row including its status and the raw body path, failures included, verified by pointing it at a server that returns errors. A non-success backs off exponentially and the next attempt is recorded with the delay. A day with no successful poll renders as a gap in every chart, not as a flat line. Do not build yet: No charts, review diffing or screenshot work yet. ### Phase 2 · A time series nothing is allowed to overwrite Build: Store each observation against its poll id with no unique key that would tempt an upsert. Chart rating average, rating count, chart position and keyword position over time. Show the count of observation days prominently, and refuse to draw a trend line below a configured minimum number of days. Trap: Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. Trap: Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. Done when: Two polls for the same app on the same day produce two observation rows, asserted. No table in the tracking half has an update or upsert statement anywhere, verified by searching the source. Charts break across days with no successful poll and label the gap. A trend line is refused below the configured minimum day count and the interface says how many more days are needed. Do not build yet: No review diffing, screenshots or cost work yet. ### Phase 3 · New reviews since you last looked Build: Poll the public review feed per storefront, key each review on its own identifier, and store first_seen_at separately from the review's own date. Show what arrived since your last visit. Treat a changed body for an existing identifier as a new row rather than an edit in place. Trap: Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. Done when: Polling the same feed twice adds no duplicate reviews, tested against a fixture served twice. A review whose body changes produces a second row and both are visible in order, tested. first_seen_at is the poll time and review_at is the store's date, and the two are never conflated in the interface. A review that disappears from the feed stays in the database and is marked rather than deleted. Do not build yet: No screenshot pipeline or spending controls yet. ### Phase 4 · Panels, and a critic that rejects them Build: Pass your real app screens as reference images so the interface inside the phone frame stays faithful. Composite every headline as real text afterwards rather than letting the model draw glyphs. Send each render to a vision model with an explicit checklist and re-render once with the failures quoted back. Resize to the exact dimensions the store currently publishes for the display sizes you are submitting, read from the specification page on the day you build. Trap: An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. Trap: Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. Done when: Every candidate has check rows for each item on the list, stored regardless of outcome. A candidate failing any check is either re-rendered once with the failures quoted or held for review, never shipped silently. All headline text in the output is composited rather than generated, verified by producing a set with the text layer disabled. Output dimensions match the figures read from the store's current specification page, with the page URL and date recorded in the repository. Do not build yet: No cost ceiling, export or empty state work yet. ### Phase 5 · Spending, exports and honest empty states Build: Track spend per month against a ceiling and stop generating when it is reached, leaving the tracker running. Export every observation table as CSV and reimport it into an empty database. Ship the competitor, keyword difficulty and revenue panels as empty states naming the data they would need. Put the collection start date on the front page. Trap: A screenshot pipeline with two model calls per candidate and a re-render on failure costs more per iteration than you expect, and an experiment left running overnight is a bill. Count every call, hold the total against a monthly ceiling, and refuse to start a run that would cross it rather than stopping halfway through a set. Trap: Filling an empty competitor panel with something derived from your own single day of data is how a placeholder becomes a number somebody plans around. Ship the empty state with a sentence about what a catalogue of that size actually requires, and leave it empty until you have it. Done when: Reaching the monthly ceiling stops image generation with a clear message and leaves collection running, tested at the boundary. Export produces every observation table as CSV and reimports into an empty database with identical row counts, tested. Panels requiring a catalogue render as empty states with an explanation and contain no computed figure, verified by review. The front page shows the collection start date and the number of observation days. Do not build yet: Nothing after this. ### Out of scope (and why) - Competitor intelligence and keyword difficulty scores, which are reads over a catalogue of millions of apps across dozens of storefronts with months of history and cannot be approximated from your own apps. - Revenue and download estimates, which are calibrated against that same catalogue and are a model of other people's data, not a measurement of yours. - Tracking apps you do not own, which turns a polite personal poller into a crawler with a very different relationship to the endpoints it depends on. - Automated store submission, which needs the store's authenticated publishing interface and its own review process rather than a folder of images. ### README must contain - That every table in the tracking half is insert only and that an upsert would destroy the product - That the tracker is worth little until it has been collecting for a month, and what the front page day count means - Where the current rate limit figure came from, with the URL and the date it was read - That headlines are composited as real text and that every render is checked for invented ratings and badges - The two API keys, the cost per screenshot set measured rather than estimated, and the monthly ceiling
# Architecture · AppGrowKit ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 and TypeScript with a small local interface, one machine | this is a tool for your own apps, not a service | | Storage | SQLite, insert only, with raw responses kept on disk | the history is the product and it has to be reconstructable | | Collection | A scheduler with a rate limit read from the current published figure | the endpoints need no key, so throttling arrives as silence | | Images | An image model for panel composition, real text composited afterwards | model-drawn glyphs are the tell and they fail at thumbnail size | | Review | A vision model checking every render against an explicit list | you will not spot a sliced element in a six panel set by eye | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Collector | Scheduling, rate limiting, back-off and poll rows. | Any transport, provided every attempt is recorded. | | Store | Insert-only observation tables and raw bodies. | Any database, provided nothing acquires an upsert. | | Reviews | Identity-keyed diffing and first-seen tracking. | Any feed, provided position is never the key. | | Generator | Reference images, panel composition, real text. | Any image model, provided headlines stay composited. | | Critic | The checklist, the re-render and the stored results. | Any vision model, provided failures are stored not discarded. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Apps, polls and every observation. This is the asset; back it up daily. - `RAW_RESPONSE_DIR` · required · Raw bodies keyed by hash, so any observation can be re-derived. - `POLL_INTERVAL_MS` · required · Derived from the currently published rate limit with headroom. Recheck it. - `USER_AGENT` · required · Identify yourself with a real contact address on every request. - `IMAGE_API_KEY` · required, secret · Panel generation. Also set a provider-side spend limit. - `VISION_API_KEY` · required, secret · The critic pass. May be the same provider as generation. - `MONTHLY_COST_CEILING` · required · Generation stops here for the month; collection keeps running. - `MIN_TREND_DAYS` · required · Below this, the interface shows points and refuses to draw a trend. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · AppGrowKit product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 and TypeScript with a small local interface, one machine, SQLite, insert only, with raw responses kept on disk, A scheduler with a rate limit read from the current published figure, An image model for panel composition, real text composited afterwards, A vision model checking every render against an explicit list. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. - A screenshot pipeline with two model calls per candidate and a re-render on failure costs more per iteration than you expect, and an experiment left running overnight is a bill. Count every call, hold the total against a monthly ceiling, and refuse to start a run that would cross it rather than stopping halfway through a set. - Filling an empty competitor panel with something derived from your own single day of data is how a placeholder becomes a number somebody plans around. Ship the empty state with a sentence about what a catalogue of that size actually requires, and leave it empty until you have it.
# Delivery milestones · AppGrowKit Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A poller that behaves and keeps its receipts Collection runs on a schedule, stays under the published rate, and records every request including the failures. ### Steps 1. Read the current published rate limit and set the interval Do not copy a number from a blog post, including this one. Files: `src/collect/schedule.ts`, `docs/rate-limit.md` 2. Write a poll row for every request The failures are what make a gap explainable. Files: `src/collect/poll.ts`, `tests/poll.test.ts` 3. Back off exponentially on any non-success Silence is what throttling looks like from here. Files: `src/collect/backoff.ts` ### Done when - [ ] The configured rate is never exceeded under test - [ ] Every request writes a poll row, including failures - [ ] Non-success backs off and the delay is recorded - [ ] A day with no successful poll renders as a gap ### Watch out - Retrying a failed request immediately against an unauthenticated public endpoint gets your address throttled, and because there is no key and no dashboard you find out by receiving nothing rather than by receiving an error you can read. Apple's Search API page publishes a figure of approximately twenty calls per minute and says it is subject to change, so read that page on the day you build, set your interval from it with room to spare, back off exponentially on any non-success, and cache lookups instead of repeating them. - Not recording the failures makes a gap in the chart indistinguishable from a day your app had no rank. Write a row for every attempt, and make the interface draw a gap where there is no successful poll instead of joining the line across it. ## M2 · A time series nothing is allowed to overwrite Ratings, ranks and keyword positions accumulate as insert-only observations tied to the poll that saw them. ### Steps 1. Insert observations, never reconcile them The chart is the product; the current value is a by-product. Files: `src/store/observations.ts`, `tests/insert-only.test.ts` 2. Break the line across missing days A tidy chart with invented points is worse than a gappy one. Files: `src/ui/charts.tsx` 3. Refuse a trend below a minimum day count Two points is not a trend however tempting the slope is. Files: `src/analysis/trend.ts` ### Done when - [ ] Two same-day polls yield two rows - [ ] No update or upsert exists in the tracking code - [ ] Charts break and label gaps - [ ] Trend lines are refused below the minimum day count ### Watch out - Keying today's rating on the app id so tomorrow's poll updates the row makes the app look correct and destroys the only thing you were building, and because the current value is always right you will not notice for months. Use an autoincrementing observation id, add no unique constraint on the app and storefront, and put a test in the suite that inserts twice for the same app on the same day and asserts two rows. - Interpolating across a missing day makes a chart look tidy and quietly invents a measurement. Break the line, and label the gap with how many polls failed in that window. ## M3 · New reviews since you last looked The review feed is diffed by review identity so nothing is double counted and nothing is missed. ### Steps 1. Key on the review identifier, never on position The feed reorders and pages without telling you. Files: `src/collect/reviews.ts`, `tests/reviews.test.ts` 2. Keep first seen separate from the review's own date One is your observation and one is theirs. Files: `src/store/reviews.ts` 3. Retain reviews that vanish from the feed Disappearing is information, not a reason to delete. Files: `src/collect/reviews.ts` ### Done when - [ ] Polling twice creates no duplicate reviews - [ ] An edited review produces a second row - [ ] First seen and review date are distinct in the interface - [ ] A vanished review is marked, not deleted ### Watch out - Keying reviews on their position in the feed gives you duplicates whenever the ordering shifts and silent misses whenever a page boundary moves, which both happen routinely on a paged public feed. Key on the review identifier, keep first_seen_at as your own observation, and never assume the feed is stable between polls. ## M4 · Panels, and a critic that rejects them Screenshot sets are generated from your real screens and every candidate is checked before you see it. ### Steps 1. Pass the real screens as reference images Otherwise the phone in the panel runs an app you do not have. Files: `src/screens/generate.ts` 2. Composite headlines as real text after generation Model-drawn type is the thing reviewers notice first. Files: `src/screens/compose.ts` 3. Store every critic check, passed or failed The failures are how you improve the prompt. Files: `src/screens/critic.ts`, `tests/critic.test.ts` ### Done when - [ ] Every candidate has stored check rows - [ ] A failing candidate is re-rendered or held, never shipped - [ ] Headlines are composited, not generated - [ ] Dimensions match the currently published specification ### Watch out - An image model given a written brief will invent star ratings, award badges, download counts and text that is nearly-but-not-quite words, and at thumbnail size all of it looks fine to you and none of it is true about your app. Check every render against a named list covering garbled text, elements sliced by a panel edge, invented ratings or badges, and hardware that contradicts the style, and store the check results whether they passed or not. - Letting the model render your headline means the type is subtly wrong in a different way in every panel and the set stops looking like one set. Composite headlines as real text with a real font after generation, which also makes changing the wording free. ## M5 · Spending, exports and honest empty states (production only) Image generation has a ceiling, the history exports, and the panels you cannot fill say why. ### Steps 1. Refuse a run that would cross the ceiling Stopping halfway through a set wastes what you spent. Files: `src/cost/ceiling.ts`, `tests/cost.test.ts` 2. Round trip the whole history through CSV The history is the asset, so it has to be portable. Files: `src/export/csv.ts` 3. Ship the impossible panels as empty states An honest blank beats a plausible invention. Files: `src/ui/empty-states.tsx` ### Done when - [ ] The monthly ceiling stops generation, not collection - [ ] Export and reimport preserve row counts - [ ] Catalogue panels contain no computed figure - [ ] Collection start date and day count are on the front page ### Watch out - A screenshot pipeline with two model calls per candidate and a re-render on failure costs more per iteration than you expect, and an experiment left running overnight is a bill. Count every call, hold the total against a monthly ceiling, and refuse to start a run that would cross it rather than stopping halfway through a set. - Filling an empty competitor panel with something derived from your own single day of data is how a placeholder becomes a number somebody plans around. Ship the empty state with a sentence about what a catalogue of that size actually requires, and leave it empty until you have it.
# Operations · AppGrowKit ## Backup Daily, and this one genuinely matters: the database holds observations that cannot be re-collected, unlike almost everything else you will ever back up. Keep the raw response directory too, because it lets you rebuild a derived table after fixing a parsing bug. ## Restore Restore both the database and the raw directory, confirm the observation day count matches the last recorded figure, and re-derive one table from raw bodies to prove the pair is consistent. A restore that leaves a hole in the history is a permanent hole. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Polls attempted against polls succeeded per day, the longest gap in the last month, spend against the ceiling, and the proportion of screenshot candidates failing the critic. A rising failure rate on polls usually means throttling rather than an outage. ## Incident checklist The incident is a silent collection failure discovered weeks later, which is why the poll table records failures and the front page shows the day count. There is no recovery for a missed day, so the monitoring is the mitigation. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No update or upsert is confirmed present in the tracking code - [ ] The configured rate limit is confirmed never exceeded under test - [ ] Failed polls are confirmed recorded and rendered as gaps - [ ] Headlines are confirmed composited rather than model drawn - [ ] Catalogue-dependent panels are confirmed to contain no computed figure ## Launch constraint Do not market omitted AppGrowKit capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Apps, polls and every observation. This is the asset; back it up daily. DATABASE_PATH=./data/aso.db # Required. Raw bodies keyed by hash, so any observation can be re-derived. RAW_RESPONSE_DIR=./data/raw # Required. Derived from the currently published rate limit with headroom. Recheck it. POLL_INTERVAL_MS=3000 # Required. Identify yourself with a real contact address on every request. USER_AGENT=my-aso-tracker/1.0 (contact@example.com) # Required · secret. Panel generation. Also set a provider-side spend limit. IMAGE_API_KEY=sk-...redacted # Required · secret. The critic pass. May be the same provider as generation. VISION_API_KEY=sk-...redacted # Required. Generation stops here for the month; collection keeps running. MONTHLY_COST_CEILING=20.00 # Required. Below this, the interface shows points and refuses to draw a trend. MIN_TREND_DAYS=14
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because the scrape is easy and the history is not. Anyone can pull today's chart positions from Apple's public endpoints; nobody can pull last quarter's. Scale compounds the same way: 1.7M apps across 36 storefronts is 65 GB of ClickHouse and a crawler that has been running for months, and a single-box copy polite enough not to get rate-limited spends a long time getting there. The paid tiers gate the catalog reads, not the AI, and that is the honest tell about where the cost sits. There is also real engineering in the screenshot pipeline that a one-sitting build skips: a vision critic that inspects each render for garbled text and sliced elements, two candidates scored against each other, and output resized to exact Apple dimensions. You can reach decent panels without that. Reaching consistent ones across a 6-panel set in three device formats is where the weekend goes.
xmonths of rank, rating, and keyword history you cannot backfill
xthe 1.7M-app, 36-storefront catalog behind competitor lookups and keyword difficulty
xrevenue and download estimates, which are calibrated against that catalog
xkeyword volume and competition scores, which need a corpus to be relative to
xthe MCP server that answers ASO questions from Claude, Cursor, or ChatGPT
xan accuracy pass on generated screenshots: the QA critic, best-of-2 scoring, and exact-size output
AppGrowKit pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free signup | $0/user | $0/user | 10 one-time screenshot credits; no card. |
| starter | $9/user | — | 60 credits/month; track up to 5 apps. |
| pro | $19/user | — | 180 credits/month; unlimited tracked apps; AI agent. |
| growth | $39/user | — | 500 credits/month; unlimited tracked apps. |
free tier10 one-time screenshot credits at signup; no recurring monthly free-credit allowance verified
billingmonthly + annual; annual advertised at up to 26% off; paid-plan trial lasts 3 days and includes 40 credits
hidden costsmonthly credits reset rather than roll over; annual credits are issued upfront; a trial defaults to monthly billing even when annual was selected before starting it
verified 2026-08-13 · source ↗
Vibecode AppGrowKit
Kinda. The core of AppGrowKit is buildable in a weekend with the prompt on this page, but there are real gaps: months of rank, rating, and keyword history you cannot backfill, the 1.7M-app, 36-storefront catalog behind competitor lookups and keyword difficulty. Read the honest list above before committing.
How much does AppGrowKit cost?
AppGrowKit costs about $19/month (Pro, checked 2026-08-07), which is $228 per year.
What do I lose by replacing AppGrowKit?
Honestly: months of rank, rating, and keyword history you cannot backfill; the 1.7M-app, 36-storefront catalog behind competitor lookups and keyword difficulty; revenue and download estimates, which are calibrated against that catalog; keyword volume and competition scores, which need a corpus to be relative to; the MCP server that answers ASO questions from Claude, Cursor, or ChatGPT; an accuracy pass on generated screenshots: the QA critic, best-of-2 scoring, and exact-size output. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to AppGrowKit?
Yes: SerpBear (Self-hosted rank tracker for keyword sets you own. Web SEO rather than App Store, but the same daily-poll-and-chart shape.), app-store-scraper (Node library for Apple's public lookup, search, chart, and review endpoints. This is the data layer a DIY tracker is built on.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.