Vibecode CueScout
track this build4 phases, 12 steps, beginner friendly0%The visibility-check loop is the weekend-buildable half: generate buyer questions, run them through Perplexity and ChatGPT, detect brand and competitor mentions, normalize citations, and score a GEO number. That much is close to what a $49/mo plan actually ships, since Basic only covers one engine. What does not fit in a prompt is the continuous Reddit and Hacker News scan that mines buyer questions from real threads instead of guessing them, the writing-plan-to-draft loop that turns a score into dated work, and weeks of trend history without which one run tells you almost nothing.
You are building a lean indie version of CueScout. 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 ===== # CueScout · indie build A visibility sampler whose hard problems are matching a brand name without matching every word that contains it, treating a model answer as one noisy sample rather than a measurement, and reporting change only when it exceeds the variance the same question produces on its own. Estimated effort: **multi-day**. 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 with TypeScript, a CLI plus a server-rendered dashboard | this is a scheduled job with a reading surface, not an application | | Storage | SQLite with one immutable row per sample, never updated | a run you can rerun is a run you can compare, and overwriting destroys the only history you have | | Providers | Documented APIs only, one adapter per provider, keys in the environment | driving a web interface to dodge the quota is both fragile and against the terms you agreed to | | Matching | Token-boundary matching against an explicit alias list | substring matching against a short brand name turns every run into noise | | Reporting | Counts, sample sizes and spread on every figure | a single number with no denominator is the thing this category is usually selling | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the CLI, the scheduler and the dashboard. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **API keys for at least two answer providers** · pay per call; the estimate in this build is what stops it surprising you - Why: One provider is one model's opinion, and the whole point of sampling is seeing how much the answer moves, which needs more than one source of movement. - Get it: Create keys through each provider's documented developer console and set a spend limit there as well. - Verify: One question returns a parsed answer with citations from each provider - [ ] **The alias list and the known false positives** · free - Why: The matcher is the difference between a real signal and a chart that responds to unrelated English, and the false positives have to be written down before the matcher exists. - Get it: List every spelling, misspelling and abbreviation of your product and competitors, plus words that would wrongly match. - Verify: Both lists are in the fixture file and the suite fails if a false positive matches - [ ] **A starting question set you did not generate from your own marketing** · free - Why: Questions written from your own positioning ask what you would like to be known for rather than what anyone actually asks, and the resulting figures flatter you. - Get it: Collect real questions from your support inbox, sales calls and search console queries. - Verify: At least twenty questions exist and their origin is recorded on each row - [ ] **The monthly ceiling and samples per question** · free - Why: These two numbers together determine both the cost and whether the output means anything, and setting them after the first bill means setting samples to one. - Get it: Decide the monthly ceiling first, then work out how many samples per question it affords at your question count. - Verify: Both figures are in the configuration and the estimator refuses a run that would cross the ceiling ## 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: - Scraping answer engine web interfaces. - Search rank tracking. - Continuous forum monitoring. - Content plans and draft generation. - the continuous Reddit and Hacker News scan and buyer-question clustering, which finds where buyers are already asking instead of guessing questions to feed a model - Google rank badges on matched threads, sourced from a paid search API - the writing-plan-to-draft loop that turns a gap into a dated 30-day plan and AI-ready page drafts, not just a dashboard number - weeks of trend history and a competitor 'what moved' digest; a single run is mostly noise - a hosted, shareable report link you can hand a client without exposing your own infra If one of those is essential to you, that is the reason to keep paying for CueScout, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · CueScout 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 a tracker that samples what answer engines say about your product and reports the answer as counts with their spread to replace CueScout. 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 There is no ranking here to measure, so do not build an interface that looks like one. Google's own search documentation states that there are no additional requirements to appear in AI Overviews or AI Mode and no special optimisations, no machine readable files and no special structured data that make it happen. The same documentation says that sites appearing in those features are folded into overall search traffic in Search Console rather than reported separately. So the platform publishes neither a lever you can pull nor a figure you can check yourself against. What you can actually do is sample. Ask a question, record what came back, ask it again, and see how much the answer moves on its own. That means: every figure this app shows carries the number of samples behind it, each question is asked more than once per run, the run-to-run spread is computed and displayed next to any change, and a movement smaller than that spread is reported as no change rather than as a trend. Do not call any of it a rank, a position or a score out of a hundred, because each of those words implies a league table that the platforms do not publish. And use the documented APIs. Do not drive an answer engine's web interface with a browser to avoid paying for calls, whatever the plan's quota looks like. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a CLI plus a server-rendered dashboard · this is a scheduled job with a reading surface, not an application - Storage: SQLite with one immutable row per sample, never updated · a run you can rerun is a run you can compare, and overwriting destroys the only history you have - Providers: Documented APIs only, one adapter per provider, keys in the environment · driving a web interface to dodge the quota is both fragile and against the terms you agreed to - Matching: Token-boundary matching against an explicit alias list · substring matching against a short brand name turns every run into noise - Reporting: Counts, sample sizes and spread on every figure · a single number with no denominator is the thing this category is usually selling ### Data model (create this before Phase 1) - products(id, name, aliases_json, domains_json, description) · aliases are explicit, including the awkward ones and the misspellings - competitors(id, product_id, name, aliases_json, domains_json) - questions(id, product_id, text, origin, added_at, retired_at) · origin is generated, supplied or observed, and retired questions stay for history - runs(id, product_id, started_at, finished_at, samples_per_question, provider_set_json, cost_minor) - samples(id, run_id, question_id, provider, attempt, model_id, raw_response, finish_reason, error_text, latency_ms, created_at) · never updated, never deleted - mentions(sample_id, entity_kind, entity_id, count, first_offset) · entity_kind is product or competitor - citations(sample_id, url_raw, host, path_canonical, is_owned) · normalised at write time and the raw URL kept beside it - The primary key of the truth in this app is the sample, not the run. A run is an aggregation over samples and can be recomputed from them at any time. ### Phase 1 · An identity you can match without lying to yourself Build: Define the product with a name, an explicit alias list, owned domains and up to five competitors with the same shape. Match case-insensitively on word boundaries, handling punctuation, hyphens and possessives. Build a fixture file of true and false matches before writing the matcher. Trap: Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. Done when: A fixture set of at least thirty positive and thirty negative strings passes, including the brand name embedded inside a longer word and the brand name as a possessive. An alias list with an entry that is a substring of a common English word is flagged at configuration time with the word named. Competitor matching uses the same code path as product matching, asserted by review. Matching is case-insensitive and unaffected by surrounding punctuation, tested. Do not build yet: No provider calls, sampling, scoring or reporting yet. ### Phase 2 · A run is many samples, not one answer Build: For each question and provider, send the configured number of attempts, storing each response as its own row with the model identifier, finish reason, latency and any error text. Retry transport failures with backoff and record the retry as a separate attempt. A failed attempt is a stored row, not a gap. Trap: Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. Done when: A run stores one row per question, provider and attempt, and the count matches the configured samples per question exactly, asserted in the suite. A provider error stores a row with its error text and does not reduce the attempt count silently. No code path updates or deletes a sample row, verified by review and by a database trigger that refuses updates. Rerunning the aggregation over stored samples reproduces the previous run figures exactly. Do not build yet: No citation normalisation, scoring or reporting yet. ### Phase 3 · Citations normalised once, kept raw forever Build: Extract every cited URL from each sample. Store the raw string, then a normalised host and canonical path with tracking parameters stripped, trailing slashes resolved and known redirector wrappers unwrapped. Mark citations whose host is in the product's owned domains. Trap: Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. Done when: Every citation row holds both the raw URL and the derived host and path, asserted on a real run. Recomputing normalisation over the whole table produces the same results and changes no raw values, tested. Two URLs differing only by tracking parameters or a trailing slash normalise to the same value, and two genuinely different pages do not, tested with fixtures. Owned-domain marking uses the configured domain list and handles subdomains explicitly rather than by substring. Do not build yet: No scoring, trend reporting or scheduling yet. ### Phase 4 · Change, only when it is bigger than the noise Build: Compute per run: the share of samples mentioning the product, the share citing an owned domain, and the same for each competitor, each with its sample count. Compute the within-run spread across attempts of the same question. When comparing two runs, display the difference beside the spread and label anything smaller than it as within noise. Trap: Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. Done when: Every displayed rate shows its numerator and denominator, checked by review of each view. The within-run spread is computed from repeated attempts of the same question and stored on the run. A between-run difference smaller than the spread renders as within noise rather than as an arrow, tested with constructed data. No view anywhere displays a combined score without the component counts beside it. Do not build yet: No scheduling, cost ceiling, export or sharing yet. ### Phase 5 · Scheduling, spend and a report you can hand over Build: Add a scheduled run with a lock so two do not overlap. Estimate the cost of a run before it starts from the configured samples and providers, refuse to start if it would cross the monthly ceiling, and record actual spend per run. Export samples, mentions and citations as CSV, and render a static HTML report for a date range that opens without this application. Trap: A scheduler with no lock and a retrying wrapper will start a second run while the first is still going, doubling the day's spend and producing two partial runs that both look like the day's answer. Take a lock, record the run as started before the first call, and make a crashed run resumable rather than restartable. Done when: Two schedulers starting at once produce one run, tested by racing them. A run whose estimated cost would cross the monthly ceiling is refused before any call is made, with the estimate and the remaining budget shown. A crashed run resumes from the samples already stored rather than starting again, tested by killing it midway. The static report opens from the filesystem, shows every figure with its counts, and names the providers and model identifiers used. Do not build yet: Nothing after this. ### Out of scope (and why) - Scraping answer engine web interfaces to avoid API charges, which breaks the terms you agreed to and produces data you cannot defend to anyone. - Search rank tracking, which needs a paid search API and is a different measurement with its own long history of caveats. - Continuous forum monitoring for buyer questions, which is a separate ingestion product with its own platform terms and rate limits to read first. - Turning a gap into a dated content plan and drafts, which is the orchestration half of the paid product and deserves its own build rather than a tab. ### README must contain - That the platforms publish no ranking for these features and no way to verify your own appearance, with the documentation quoted and dated - That every figure is a sample with a denominator, and how many samples per question a run takes - How the within-run spread is computed and why differences smaller than it are reported as no change - That sample rows are immutable and how to recompute every derived figure from them - The estimated cost of one run at the configured settings, and what happens at the monthly ceiling ===== AGENTS.md ===== # Agent instructions · CueScout indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript, a CLI plus a server-rendered dashboard, SQLite with one immutable row per sample, never updated, Documented APIs only, one adapter per provider, keys in the environment, Token-boundary matching against an explicit alias list, Counts, sample sizes and spread on every figure. 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 - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. ===== BUILD_PLAN.md ===== # Build plan · CueScout A visibility sampler whose hard problems are matching a brand name without matching every word that contains it, treating a model answer as one noisy sample rather than a measurement, and reporting change only when it exceeds the variance the same question produces on its own. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An identity you can match without lying to yourself Brand and competitor detection works on token boundaries against an explicit alias list, and is tested against the cases that break it. ### Steps 1. Write the fixture file before the matcher The negatives are the part that matters and they are easy to invent afterwards to fit. Files: `fixtures/matching.json`, `src/match/index.ts` 2. Match on token boundaries, not substrings Including hyphens, possessives and trailing punctuation. Files: `src/match/tokens.ts`, `tests/match.test.ts` 3. Warn at configuration time about dangerous aliases A two-letter alias will match everything and nobody will notice for a month. Files: `src/config/product.ts` ### Done when - [ ] Thirty positive and thirty negative fixtures pass - [ ] A brand name inside a longer word does not match - [ ] Competitors use the same matcher as the product - [ ] A substring-risky alias is flagged at configuration time ### Watch out - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. ## Phase 2 · A run is many samples, not one answer Every question is asked several times per provider per run, and every response is stored immutably with its errors. ### Steps 1. One row per attempt, including failures A gap in the data looks identical to a zero and means something else. Files: `src/run/sample.ts`, `src/db/schema.sql` 2. Refuse updates to samples at the database level The immutability rule enforced somewhere it cannot be forgotten. Files: `src/db/schema.sql`, `tests/immutable.test.ts` 3. Retry transport failures, record each attempt A retry is not the same event as the original call. Files: `src/providers/retry.ts` ### Done when - [ ] Attempt count matches the configured samples exactly - [ ] Provider errors are stored rows with their text - [ ] Sample updates are refused by the database - [ ] Aggregation is reproducible from stored samples ### Watch out - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. ## Phase 3 · Citations normalised once, kept raw forever Cited URLs are reduced to a comparable form without losing what the provider actually returned. ### Steps 1. Store the raw URL beside the derived fields Normalisation rules change and history should not. Files: `src/citations/store.ts` 2. Strip tracking parameters and unwrap redirectors With a fixture file of real examples from your own runs. Files: `src/citations/normalise.ts`, `fixtures/urls.json` 3. Make renormalisation a single command So a fix to the rules applies to years of data. Files: `src/cli/renormalise.ts`, `tests/renormalise.test.ts` ### Done when - [ ] Raw and derived values both stored - [ ] Renormalisation is idempotent and leaves raw untouched - [ ] Tracking-parameter variants collapse, different pages do not - [ ] Owned-domain marking handles subdomains explicitly ### Watch out - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. ## Phase 4 · Change, only when it is bigger than the noise Figures are reported with their sample size and spread, and a difference inside the spread is reported as no change. ### Steps 1. Compute rates with their counts attached The denominator is the part that stops this being astrology. Files: `src/report/rates.ts`, `tests/rates.test.ts` 2. Compute the within-run spread from repeated attempts Your own error bar, measured rather than assumed. Files: `src/report/spread.ts` 3. Label sub-spread differences as within noise Not an arrow, not a colour, a sentence. Files: `src/views/compare.tsx` ### Done when - [ ] Every rate shows numerator and denominator - [ ] Within-run spread is stored on the run - [ ] A sub-spread difference renders as within noise - [ ] No combined score appears without its components ### Watch out - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. ## Not in this build - Scraping answer engine web interfaces. - Search rank tracking. - Continuous forum monitoring. - Content plans and draft generation. ## After v1, if you want it - A second matcher pass that records the sentence around each mention, so you can see whether being mentioned was good - A question retirement workflow, since a question nobody asks any more still costs a sample every run - A per-question stability figure, which tells you which questions are worth tracking at all ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Products, questions, runs and every sample ever taken. DATABASE_PATH=./data/visibility.db # Required · secret. First answer provider. Set a spend limit in their console too. PROVIDER_A_KEY=pplx-...redacted # Required · secret. Second answer provider. Two sources of movement, not one. PROVIDER_B_KEY=sk-...redacted # Required. Attempts per question per provider per run. Below three the spread is meaningless. SAMPLES_PER_QUESTION=5 # Required. A run is refused before any call if its estimate would cross this. MONTHLY_COST_CEILING=20.00 # Required. Stops two scheduled runs overlapping and doubling the bill. RUN_LOCK_PATH=./data/run.lock # Required. Used to mark owned citations. Subdomains are explicit, not inferred. OWNED_DOMAINS=example.com,docs.example.com
You are building a lean indie version of CueScout. 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 ===== # CueScout · indie build A visibility sampler whose hard problems are matching a brand name without matching every word that contains it, treating a model answer as one noisy sample rather than a measurement, and reporting change only when it exceeds the variance the same question produces on its own. Estimated effort: **multi-day**. 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 with TypeScript, a CLI plus a server-rendered dashboard | this is a scheduled job with a reading surface, not an application | | Storage | SQLite with one immutable row per sample, never updated | a run you can rerun is a run you can compare, and overwriting destroys the only history you have | | Providers | Documented APIs only, one adapter per provider, keys in the environment | driving a web interface to dodge the quota is both fragile and against the terms you agreed to | | Matching | Token-boundary matching against an explicit alias list | substring matching against a short brand name turns every run into noise | | Reporting | Counts, sample sizes and spread on every figure | a single number with no denominator is the thing this category is usually selling | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the CLI, the scheduler and the dashboard. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **API keys for at least two answer providers** · pay per call; the estimate in this build is what stops it surprising you - Why: One provider is one model's opinion, and the whole point of sampling is seeing how much the answer moves, which needs more than one source of movement. - Get it: Create keys through each provider's documented developer console and set a spend limit there as well. - Verify: One question returns a parsed answer with citations from each provider - [ ] **The alias list and the known false positives** · free - Why: The matcher is the difference between a real signal and a chart that responds to unrelated English, and the false positives have to be written down before the matcher exists. - Get it: List every spelling, misspelling and abbreviation of your product and competitors, plus words that would wrongly match. - Verify: Both lists are in the fixture file and the suite fails if a false positive matches - [ ] **A starting question set you did not generate from your own marketing** · free - Why: Questions written from your own positioning ask what you would like to be known for rather than what anyone actually asks, and the resulting figures flatter you. - Get it: Collect real questions from your support inbox, sales calls and search console queries. - Verify: At least twenty questions exist and their origin is recorded on each row - [ ] **The monthly ceiling and samples per question** · free - Why: These two numbers together determine both the cost and whether the output means anything, and setting them after the first bill means setting samples to one. - Get it: Decide the monthly ceiling first, then work out how many samples per question it affords at your question count. - Verify: Both figures are in the configuration and the estimator refuses a run that would cross the ceiling ## 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: - Scraping answer engine web interfaces. - Search rank tracking. - Continuous forum monitoring. - Content plans and draft generation. - the continuous Reddit and Hacker News scan and buyer-question clustering, which finds where buyers are already asking instead of guessing questions to feed a model - Google rank badges on matched threads, sourced from a paid search API - the writing-plan-to-draft loop that turns a gap into a dated 30-day plan and AI-ready page drafts, not just a dashboard number - weeks of trend history and a competitor 'what moved' digest; a single run is mostly noise - a hosted, shareable report link you can hand a client without exposing your own infra If one of those is essential to you, that is the reason to keep paying for CueScout, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · CueScout 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 a tracker that samples what answer engines say about your product and reports the answer as counts with their spread to replace CueScout. 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 There is no ranking here to measure, so do not build an interface that looks like one. Google's own search documentation states that there are no additional requirements to appear in AI Overviews or AI Mode and no special optimisations, no machine readable files and no special structured data that make it happen. The same documentation says that sites appearing in those features are folded into overall search traffic in Search Console rather than reported separately. So the platform publishes neither a lever you can pull nor a figure you can check yourself against. What you can actually do is sample. Ask a question, record what came back, ask it again, and see how much the answer moves on its own. That means: every figure this app shows carries the number of samples behind it, each question is asked more than once per run, the run-to-run spread is computed and displayed next to any change, and a movement smaller than that spread is reported as no change rather than as a trend. Do not call any of it a rank, a position or a score out of a hundred, because each of those words implies a league table that the platforms do not publish. And use the documented APIs. Do not drive an answer engine's web interface with a browser to avoid paying for calls, whatever the plan's quota looks like. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a CLI plus a server-rendered dashboard · this is a scheduled job with a reading surface, not an application - Storage: SQLite with one immutable row per sample, never updated · a run you can rerun is a run you can compare, and overwriting destroys the only history you have - Providers: Documented APIs only, one adapter per provider, keys in the environment · driving a web interface to dodge the quota is both fragile and against the terms you agreed to - Matching: Token-boundary matching against an explicit alias list · substring matching against a short brand name turns every run into noise - Reporting: Counts, sample sizes and spread on every figure · a single number with no denominator is the thing this category is usually selling ### Data model (create this before Phase 1) - products(id, name, aliases_json, domains_json, description) · aliases are explicit, including the awkward ones and the misspellings - competitors(id, product_id, name, aliases_json, domains_json) - questions(id, product_id, text, origin, added_at, retired_at) · origin is generated, supplied or observed, and retired questions stay for history - runs(id, product_id, started_at, finished_at, samples_per_question, provider_set_json, cost_minor) - samples(id, run_id, question_id, provider, attempt, model_id, raw_response, finish_reason, error_text, latency_ms, created_at) · never updated, never deleted - mentions(sample_id, entity_kind, entity_id, count, first_offset) · entity_kind is product or competitor - citations(sample_id, url_raw, host, path_canonical, is_owned) · normalised at write time and the raw URL kept beside it - The primary key of the truth in this app is the sample, not the run. A run is an aggregation over samples and can be recomputed from them at any time. ### Phase 1 · An identity you can match without lying to yourself Build: Define the product with a name, an explicit alias list, owned domains and up to five competitors with the same shape. Match case-insensitively on word boundaries, handling punctuation, hyphens and possessives. Build a fixture file of true and false matches before writing the matcher. Trap: Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. Done when: A fixture set of at least thirty positive and thirty negative strings passes, including the brand name embedded inside a longer word and the brand name as a possessive. An alias list with an entry that is a substring of a common English word is flagged at configuration time with the word named. Competitor matching uses the same code path as product matching, asserted by review. Matching is case-insensitive and unaffected by surrounding punctuation, tested. Do not build yet: No provider calls, sampling, scoring or reporting yet. ### Phase 2 · A run is many samples, not one answer Build: For each question and provider, send the configured number of attempts, storing each response as its own row with the model identifier, finish reason, latency and any error text. Retry transport failures with backoff and record the retry as a separate attempt. A failed attempt is a stored row, not a gap. Trap: Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. Done when: A run stores one row per question, provider and attempt, and the count matches the configured samples per question exactly, asserted in the suite. A provider error stores a row with its error text and does not reduce the attempt count silently. No code path updates or deletes a sample row, verified by review and by a database trigger that refuses updates. Rerunning the aggregation over stored samples reproduces the previous run figures exactly. Do not build yet: No citation normalisation, scoring or reporting yet. ### Phase 3 · Citations normalised once, kept raw forever Build: Extract every cited URL from each sample. Store the raw string, then a normalised host and canonical path with tracking parameters stripped, trailing slashes resolved and known redirector wrappers unwrapped. Mark citations whose host is in the product's owned domains. Trap: Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. Done when: Every citation row holds both the raw URL and the derived host and path, asserted on a real run. Recomputing normalisation over the whole table produces the same results and changes no raw values, tested. Two URLs differing only by tracking parameters or a trailing slash normalise to the same value, and two genuinely different pages do not, tested with fixtures. Owned-domain marking uses the configured domain list and handles subdomains explicitly rather than by substring. Do not build yet: No scoring, trend reporting or scheduling yet. ### Phase 4 · Change, only when it is bigger than the noise Build: Compute per run: the share of samples mentioning the product, the share citing an owned domain, and the same for each competitor, each with its sample count. Compute the within-run spread across attempts of the same question. When comparing two runs, display the difference beside the spread and label anything smaller than it as within noise. Trap: Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. Done when: Every displayed rate shows its numerator and denominator, checked by review of each view. The within-run spread is computed from repeated attempts of the same question and stored on the run. A between-run difference smaller than the spread renders as within noise rather than as an arrow, tested with constructed data. No view anywhere displays a combined score without the component counts beside it. Do not build yet: No scheduling, cost ceiling, export or sharing yet. ### Phase 5 · Scheduling, spend and a report you can hand over Build: Add a scheduled run with a lock so two do not overlap. Estimate the cost of a run before it starts from the configured samples and providers, refuse to start if it would cross the monthly ceiling, and record actual spend per run. Export samples, mentions and citations as CSV, and render a static HTML report for a date range that opens without this application. Trap: A scheduler with no lock and a retrying wrapper will start a second run while the first is still going, doubling the day's spend and producing two partial runs that both look like the day's answer. Take a lock, record the run as started before the first call, and make a crashed run resumable rather than restartable. Done when: Two schedulers starting at once produce one run, tested by racing them. A run whose estimated cost would cross the monthly ceiling is refused before any call is made, with the estimate and the remaining budget shown. A crashed run resumes from the samples already stored rather than starting again, tested by killing it midway. The static report opens from the filesystem, shows every figure with its counts, and names the providers and model identifiers used. Do not build yet: Nothing after this. ### Out of scope (and why) - Scraping answer engine web interfaces to avoid API charges, which breaks the terms you agreed to and produces data you cannot defend to anyone. - Search rank tracking, which needs a paid search API and is a different measurement with its own long history of caveats. - Continuous forum monitoring for buyer questions, which is a separate ingestion product with its own platform terms and rate limits to read first. - Turning a gap into a dated content plan and drafts, which is the orchestration half of the paid product and deserves its own build rather than a tab. ### README must contain - That the platforms publish no ranking for these features and no way to verify your own appearance, with the documentation quoted and dated - That every figure is a sample with a denominator, and how many samples per question a run takes - How the within-run spread is computed and why differences smaller than it are reported as no change - That sample rows are immutable and how to recompute every derived figure from them - The estimated cost of one run at the configured settings, and what happens at the monthly ceiling ===== AGENTS.md ===== # Agent instructions · CueScout indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript, a CLI plus a server-rendered dashboard, SQLite with one immutable row per sample, never updated, Documented APIs only, one adapter per provider, keys in the environment, Token-boundary matching against an explicit alias list, Counts, sample sizes and spread on every figure. 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 - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. ===== BUILD_PLAN.md ===== # Build plan · CueScout A visibility sampler whose hard problems are matching a brand name without matching every word that contains it, treating a model answer as one noisy sample rather than a measurement, and reporting change only when it exceeds the variance the same question produces on its own. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An identity you can match without lying to yourself Brand and competitor detection works on token boundaries against an explicit alias list, and is tested against the cases that break it. ### Steps 1. Write the fixture file before the matcher The negatives are the part that matters and they are easy to invent afterwards to fit. Files: `fixtures/matching.json`, `src/match/index.ts` 2. Match on token boundaries, not substrings Including hyphens, possessives and trailing punctuation. Files: `src/match/tokens.ts`, `tests/match.test.ts` 3. Warn at configuration time about dangerous aliases A two-letter alias will match everything and nobody will notice for a month. Files: `src/config/product.ts` ### Done when - [ ] Thirty positive and thirty negative fixtures pass - [ ] A brand name inside a longer word does not match - [ ] Competitors use the same matcher as the product - [ ] A substring-risky alias is flagged at configuration time ### Watch out - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. ## Phase 2 · A run is many samples, not one answer Every question is asked several times per provider per run, and every response is stored immutably with its errors. ### Steps 1. One row per attempt, including failures A gap in the data looks identical to a zero and means something else. Files: `src/run/sample.ts`, `src/db/schema.sql` 2. Refuse updates to samples at the database level The immutability rule enforced somewhere it cannot be forgotten. Files: `src/db/schema.sql`, `tests/immutable.test.ts` 3. Retry transport failures, record each attempt A retry is not the same event as the original call. Files: `src/providers/retry.ts` ### Done when - [ ] Attempt count matches the configured samples exactly - [ ] Provider errors are stored rows with their text - [ ] Sample updates are refused by the database - [ ] Aggregation is reproducible from stored samples ### Watch out - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. ## Phase 3 · Citations normalised once, kept raw forever Cited URLs are reduced to a comparable form without losing what the provider actually returned. ### Steps 1. Store the raw URL beside the derived fields Normalisation rules change and history should not. Files: `src/citations/store.ts` 2. Strip tracking parameters and unwrap redirectors With a fixture file of real examples from your own runs. Files: `src/citations/normalise.ts`, `fixtures/urls.json` 3. Make renormalisation a single command So a fix to the rules applies to years of data. Files: `src/cli/renormalise.ts`, `tests/renormalise.test.ts` ### Done when - [ ] Raw and derived values both stored - [ ] Renormalisation is idempotent and leaves raw untouched - [ ] Tracking-parameter variants collapse, different pages do not - [ ] Owned-domain marking handles subdomains explicitly ### Watch out - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. ## Phase 4 · Change, only when it is bigger than the noise Figures are reported with their sample size and spread, and a difference inside the spread is reported as no change. ### Steps 1. Compute rates with their counts attached The denominator is the part that stops this being astrology. Files: `src/report/rates.ts`, `tests/rates.test.ts` 2. Compute the within-run spread from repeated attempts Your own error bar, measured rather than assumed. Files: `src/report/spread.ts` 3. Label sub-spread differences as within noise Not an arrow, not a colour, a sentence. Files: `src/views/compare.tsx` ### Done when - [ ] Every rate shows numerator and denominator - [ ] Within-run spread is stored on the run - [ ] A sub-spread difference renders as within noise - [ ] No combined score appears without its components ### Watch out - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. ## Not in this build - Scraping answer engine web interfaces. - Search rank tracking. - Continuous forum monitoring. - Content plans and draft generation. ## After v1, if you want it - A second matcher pass that records the sentence around each mention, so you can see whether being mentioned was good - A question retirement workflow, since a question nobody asks any more still costs a sample every run - A per-question stability figure, which tells you which questions are worth tracking at all ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Products, questions, runs and every sample ever taken. DATABASE_PATH=./data/visibility.db # Required · secret. First answer provider. Set a spend limit in their console too. PROVIDER_A_KEY=pplx-...redacted # Required · secret. Second answer provider. Two sources of movement, not one. PROVIDER_B_KEY=sk-...redacted # Required. Attempts per question per provider per run. Below three the spread is meaningless. SAMPLES_PER_QUESTION=5 # Required. A run is refused before any call if its estimate would cross this. MONTHLY_COST_CEILING=20.00 # Required. Stops two scheduled runs overlapping and doubling the bill. RUN_LOCK_PATH=./data/run.lock # Required. Used to mark owned citations. Subdomains are explicit, not inferred. OWNED_DOMAINS=example.com,docs.example.com
You are building a production product version of CueScout. 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 ===== # CueScout · product brief ## Problem The visibility-check loop is the weekend-buildable half: generate buyer questions, run them through Perplexity and ChatGPT, detect brand and competitor mentions, normalize citations, and score a GEO number. That much is close to what a $49/mo plan actually ships, since Basic only covers one engine. What does not fit in a prompt is the continuous Reddit and Hacker News scan that mines buyer questions from real threads instead of guessing them, the writing-plan-to-draft loop that turns a score into dated work, and weeks of trend history without which one run tells you almost nothing. ## Product outcome A tracker that says how often two answer engines mentioned you out of how many tries, how much that number moves on its own, and nothing else, with a static report you can hand to someone without giving them a login. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Perplexity API key - OpenAI API key - durable per-run storage - a scheduler for repeat runs ## Explicit non-goals for v1 - Scraping answer engine web interfaces. - Search rank tracking. - Continuous forum monitoring. - Content plans and draft generation. - the continuous Reddit and Hacker News scan and buyer-question clustering, which finds where buyers are already asking instead of guessing questions to feed a model - Google rank badges on matched threads, sourced from a paid search API - the writing-plan-to-draft loop that turns a gap into a dated 30-day plan and AI-ready page drafts, not just a dashboard number - weeks of trend history and a competitor 'what moved' digest; a single run is mostly noise - a hosted, shareable report link you can hand a client without exposing your own infra ## Success criteria - The negative matching fixtures are confirmed to fail the build when they match - Sample rows are confirmed immutable at the database level - Every displayed rate is confirmed to show its counts - A sub-spread difference is confirmed to render as within noise - A run over the monthly ceiling is confirmed refused before any call ===== BRIEF.md ===== # Build brief · CueScout 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 a tracker that samples what answer engines say about your product and reports the answer as counts with their spread to replace CueScout. 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 There is no ranking here to measure, so do not build an interface that looks like one. Google's own search documentation states that there are no additional requirements to appear in AI Overviews or AI Mode and no special optimisations, no machine readable files and no special structured data that make it happen. The same documentation says that sites appearing in those features are folded into overall search traffic in Search Console rather than reported separately. So the platform publishes neither a lever you can pull nor a figure you can check yourself against. What you can actually do is sample. Ask a question, record what came back, ask it again, and see how much the answer moves on its own. That means: every figure this app shows carries the number of samples behind it, each question is asked more than once per run, the run-to-run spread is computed and displayed next to any change, and a movement smaller than that spread is reported as no change rather than as a trend. Do not call any of it a rank, a position or a score out of a hundred, because each of those words implies a league table that the platforms do not publish. And use the documented APIs. Do not drive an answer engine's web interface with a browser to avoid paying for calls, whatever the plan's quota looks like. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a CLI plus a server-rendered dashboard · this is a scheduled job with a reading surface, not an application - Storage: SQLite with one immutable row per sample, never updated · a run you can rerun is a run you can compare, and overwriting destroys the only history you have - Providers: Documented APIs only, one adapter per provider, keys in the environment · driving a web interface to dodge the quota is both fragile and against the terms you agreed to - Matching: Token-boundary matching against an explicit alias list · substring matching against a short brand name turns every run into noise - Reporting: Counts, sample sizes and spread on every figure · a single number with no denominator is the thing this category is usually selling ### Data model (create this before Phase 1) - products(id, name, aliases_json, domains_json, description) · aliases are explicit, including the awkward ones and the misspellings - competitors(id, product_id, name, aliases_json, domains_json) - questions(id, product_id, text, origin, added_at, retired_at) · origin is generated, supplied or observed, and retired questions stay for history - runs(id, product_id, started_at, finished_at, samples_per_question, provider_set_json, cost_minor) - samples(id, run_id, question_id, provider, attempt, model_id, raw_response, finish_reason, error_text, latency_ms, created_at) · never updated, never deleted - mentions(sample_id, entity_kind, entity_id, count, first_offset) · entity_kind is product or competitor - citations(sample_id, url_raw, host, path_canonical, is_owned) · normalised at write time and the raw URL kept beside it - The primary key of the truth in this app is the sample, not the run. A run is an aggregation over samples and can be recomputed from them at any time. ### Phase 1 · An identity you can match without lying to yourself Build: Define the product with a name, an explicit alias list, owned domains and up to five competitors with the same shape. Match case-insensitively on word boundaries, handling punctuation, hyphens and possessives. Build a fixture file of true and false matches before writing the matcher. Trap: Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. Done when: A fixture set of at least thirty positive and thirty negative strings passes, including the brand name embedded inside a longer word and the brand name as a possessive. An alias list with an entry that is a substring of a common English word is flagged at configuration time with the word named. Competitor matching uses the same code path as product matching, asserted by review. Matching is case-insensitive and unaffected by surrounding punctuation, tested. Do not build yet: No provider calls, sampling, scoring or reporting yet. ### Phase 2 · A run is many samples, not one answer Build: For each question and provider, send the configured number of attempts, storing each response as its own row with the model identifier, finish reason, latency and any error text. Retry transport failures with backoff and record the retry as a separate attempt. A failed attempt is a stored row, not a gap. Trap: Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. Done when: A run stores one row per question, provider and attempt, and the count matches the configured samples per question exactly, asserted in the suite. A provider error stores a row with its error text and does not reduce the attempt count silently. No code path updates or deletes a sample row, verified by review and by a database trigger that refuses updates. Rerunning the aggregation over stored samples reproduces the previous run figures exactly. Do not build yet: No citation normalisation, scoring or reporting yet. ### Phase 3 · Citations normalised once, kept raw forever Build: Extract every cited URL from each sample. Store the raw string, then a normalised host and canonical path with tracking parameters stripped, trailing slashes resolved and known redirector wrappers unwrapped. Mark citations whose host is in the product's owned domains. Trap: Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. Done when: Every citation row holds both the raw URL and the derived host and path, asserted on a real run. Recomputing normalisation over the whole table produces the same results and changes no raw values, tested. Two URLs differing only by tracking parameters or a trailing slash normalise to the same value, and two genuinely different pages do not, tested with fixtures. Owned-domain marking uses the configured domain list and handles subdomains explicitly rather than by substring. Do not build yet: No scoring, trend reporting or scheduling yet. ### Phase 4 · Change, only when it is bigger than the noise Build: Compute per run: the share of samples mentioning the product, the share citing an owned domain, and the same for each competitor, each with its sample count. Compute the within-run spread across attempts of the same question. When comparing two runs, display the difference beside the spread and label anything smaller than it as within noise. Trap: Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. Done when: Every displayed rate shows its numerator and denominator, checked by review of each view. The within-run spread is computed from repeated attempts of the same question and stored on the run. A between-run difference smaller than the spread renders as within noise rather than as an arrow, tested with constructed data. No view anywhere displays a combined score without the component counts beside it. Do not build yet: No scheduling, cost ceiling, export or sharing yet. ### Phase 5 · Scheduling, spend and a report you can hand over Build: Add a scheduled run with a lock so two do not overlap. Estimate the cost of a run before it starts from the configured samples and providers, refuse to start if it would cross the monthly ceiling, and record actual spend per run. Export samples, mentions and citations as CSV, and render a static HTML report for a date range that opens without this application. Trap: A scheduler with no lock and a retrying wrapper will start a second run while the first is still going, doubling the day's spend and producing two partial runs that both look like the day's answer. Take a lock, record the run as started before the first call, and make a crashed run resumable rather than restartable. Done when: Two schedulers starting at once produce one run, tested by racing them. A run whose estimated cost would cross the monthly ceiling is refused before any call is made, with the estimate and the remaining budget shown. A crashed run resumes from the samples already stored rather than starting again, tested by killing it midway. The static report opens from the filesystem, shows every figure with its counts, and names the providers and model identifiers used. Do not build yet: Nothing after this. ### Out of scope (and why) - Scraping answer engine web interfaces to avoid API charges, which breaks the terms you agreed to and produces data you cannot defend to anyone. - Search rank tracking, which needs a paid search API and is a different measurement with its own long history of caveats. - Continuous forum monitoring for buyer questions, which is a separate ingestion product with its own platform terms and rate limits to read first. - Turning a gap into a dated content plan and drafts, which is the orchestration half of the paid product and deserves its own build rather than a tab. ### README must contain - That the platforms publish no ranking for these features and no way to verify your own appearance, with the documentation quoted and dated - That every figure is a sample with a denominator, and how many samples per question a run takes - How the within-run spread is computed and why differences smaller than it are reported as no change - That sample rows are immutable and how to recompute every derived figure from them - The estimated cost of one run at the configured settings, and what happens at the monthly ceiling ===== ARCHITECTURE.md ===== # Architecture · CueScout ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript, a CLI plus a server-rendered dashboard | this is a scheduled job with a reading surface, not an application | | Storage | SQLite with one immutable row per sample, never updated | a run you can rerun is a run you can compare, and overwriting destroys the only history you have | | Providers | Documented APIs only, one adapter per provider, keys in the environment | driving a web interface to dodge the quota is both fragile and against the terms you agreed to | | Matching | Token-boundary matching against an explicit alias list | substring matching against a short brand name turns every run into noise | | Reporting | Counts, sample sizes and spread on every figure | a single number with no denominator is the thing this category is usually selling | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Identity | Aliases, competitors, owned domains and matching. | Any matcher that passes the fixture file, including the negatives. | | Sampling | Provider adapters, attempts and immutable rows. | Any provider with a documented API; never a browser driving a web interface. | | Citations | Raw storage and recomputable normalisation. | Any rules, provided raw URLs survive and recomputation is one command. | | Reporting | Rates with counts, spread and change labelling. | Any presentation that keeps denominators visible. | | Schedule | Locking, cost estimation and resumption. | Any scheduler, provided the lock and the pre-flight estimate stay. | ## 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 · Products, questions, runs and every sample ever taken. - `PROVIDER_A_KEY` · required, secret · First answer provider. Set a spend limit in their console too. - `PROVIDER_B_KEY` · required, secret · Second answer provider. Two sources of movement, not one. - `SAMPLES_PER_QUESTION` · required · Attempts per question per provider per run. Below three the spread is meaningless. - `MONTHLY_COST_CEILING` · required · A run is refused before any call if its estimate would cross this. - `RUN_LOCK_PATH` · required · Stops two scheduled runs overlapping and doubling the bill. - `OWNED_DOMAINS` · required · Used to mark owned citations. Subdomains are explicit, not inferred. ## 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 · CueScout product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript, a CLI plus a server-rendered dashboard, SQLite with one immutable row per sample, never updated, Documented APIs only, one adapter per provider, keys in the environment, Token-boundary matching against an explicit alias list, Counts, sample sizes and spread on every figure. - 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 - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. - A scheduler with no lock and a retrying wrapper will start a second run while the first is still going, doubling the day's spend and producing two partial runs that both look like the day's answer. Take a lock, record the run as started before the first call, and make a crashed run resumable rather than restartable. ===== MILESTONES.md ===== # Delivery milestones · CueScout Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An identity you can match without lying to yourself Brand and competitor detection works on token boundaries against an explicit alias list, and is tested against the cases that break it. ### Steps 1. Write the fixture file before the matcher The negatives are the part that matters and they are easy to invent afterwards to fit. Files: `fixtures/matching.json`, `src/match/index.ts` 2. Match on token boundaries, not substrings Including hyphens, possessives and trailing punctuation. Files: `src/match/tokens.ts`, `tests/match.test.ts` 3. Warn at configuration time about dangerous aliases A two-letter alias will match everything and nobody will notice for a month. Files: `src/config/product.ts` ### Done when - [ ] Thirty positive and thirty negative fixtures pass - [ ] A brand name inside a longer word does not match - [ ] Competitors use the same matcher as the product - [ ] A substring-risky alias is flagged at configuration time ### Watch out - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. ## M2 · A run is many samples, not one answer Every question is asked several times per provider per run, and every response is stored immutably with its errors. ### Steps 1. One row per attempt, including failures A gap in the data looks identical to a zero and means something else. Files: `src/run/sample.ts`, `src/db/schema.sql` 2. Refuse updates to samples at the database level The immutability rule enforced somewhere it cannot be forgotten. Files: `src/db/schema.sql`, `tests/immutable.test.ts` 3. Retry transport failures, record each attempt A retry is not the same event as the original call. Files: `src/providers/retry.ts` ### Done when - [ ] Attempt count matches the configured samples exactly - [ ] Provider errors are stored rows with their text - [ ] Sample updates are refused by the database - [ ] Aggregation is reproducible from stored samples ### Watch out - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. ## M3 · Citations normalised once, kept raw forever Cited URLs are reduced to a comparable form without losing what the provider actually returned. ### Steps 1. Store the raw URL beside the derived fields Normalisation rules change and history should not. Files: `src/citations/store.ts` 2. Strip tracking parameters and unwrap redirectors With a fixture file of real examples from your own runs. Files: `src/citations/normalise.ts`, `fixtures/urls.json` 3. Make renormalisation a single command So a fix to the rules applies to years of data. Files: `src/cli/renormalise.ts`, `tests/renormalise.test.ts` ### Done when - [ ] Raw and derived values both stored - [ ] Renormalisation is idempotent and leaves raw untouched - [ ] Tracking-parameter variants collapse, different pages do not - [ ] Owned-domain marking handles subdomains explicitly ### Watch out - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. ## M4 · Change, only when it is bigger than the noise Figures are reported with their sample size and spread, and a difference inside the spread is reported as no change. ### Steps 1. Compute rates with their counts attached The denominator is the part that stops this being astrology. Files: `src/report/rates.ts`, `tests/rates.test.ts` 2. Compute the within-run spread from repeated attempts Your own error bar, measured rather than assumed. Files: `src/report/spread.ts` 3. Label sub-spread differences as within noise Not an arrow, not a colour, a sentence. Files: `src/views/compare.tsx` ### Done when - [ ] Every rate shows numerator and denominator - [ ] Within-run spread is stored on the run - [ ] A sub-spread difference renders as within noise - [ ] No combined score appears without its components ### Watch out - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. ## M5 · Scheduling, spend and a report you can hand over (production only) Runs happen on a schedule inside a cost ceiling, and the output leaves as files rather than as a login. ### Steps 1. Lock the scheduled run Two overlapping runs cost double and answer nothing. Files: `src/schedule/lock.ts`, `tests/lock.test.ts` 2. Estimate before spending, refuse at the ceiling The estimate is computed from samples times providers times questions. Files: `src/cost/estimate.ts` 3. Emit a static report and a CSV export Something you can send, not something you have to log into. Files: `src/report/static.ts`, `src/cli/export.ts` ### Done when - [ ] Racing schedulers produce one run - [ ] A run over the ceiling is refused before any call - [ ] A crashed run resumes rather than restarts - [ ] The static report opens from the filesystem with counts shown ### Watch out - A scheduler with no lock and a retrying wrapper will start a second run while the first is still going, doubling the day's spend and producing two partial runs that both look like the day's answer. Take a lock, record the run as started before the first call, and make a crashed run resumable rather than restartable. ===== OPERATIONS.md ===== # Operations · CueScout ## Backup The database, which is every sample ever taken and cannot be recreated because the providers will not answer yesterday's question the same way. Everything else is derived. ## Restore Restore and rerun the aggregation, then compare the recomputed run figures against what the dashboard showed before. They should match exactly, because aggregation is a pure function of the samples. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Spend against the ceiling, the provider error rate per run, and the within-run spread over time. A spread that grows means the answers are getting less stable, which is itself the most interesting number in the app. ## Incident checklist The failure mode here is a figure someone acted on. Immutable samples with raw responses are what let you show exactly what was asked, what came back and how many times, months later. 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 - [ ] The negative matching fixtures are confirmed to fail the build when they match - [ ] Sample rows are confirmed immutable at the database level - [ ] Every displayed rate is confirmed to show its counts - [ ] A sub-spread difference is confirmed to render as within noise - [ ] A run over the monthly ceiling is confirmed refused before any call ## Launch constraint Do not market omitted CueScout 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. Products, questions, runs and every sample ever taken. DATABASE_PATH=./data/visibility.db # Required · secret. First answer provider. Set a spend limit in their console too. PROVIDER_A_KEY=pplx-...redacted # Required · secret. Second answer provider. Two sources of movement, not one. PROVIDER_B_KEY=sk-...redacted # Required. Attempts per question per provider per run. Below three the spread is meaningless. SAMPLES_PER_QUESTION=5 # Required. A run is refused before any call if its estimate would cross this. MONTHLY_COST_CEILING=20.00 # Required. Stops two scheduled runs overlapping and doubling the bill. RUN_LOCK_PATH=./data/run.lock # Required. Used to mark owned citations. Subdomains are explicit, not inferred. OWNED_DOMAINS=example.com,docs.example.com
# CueScout · indie build A visibility sampler whose hard problems are matching a brand name without matching every word that contains it, treating a model answer as one noisy sample rather than a measurement, and reporting change only when it exceeds the variance the same question produces on its own. Estimated effort: **multi-day**. 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 with TypeScript, a CLI plus a server-rendered dashboard | this is a scheduled job with a reading surface, not an application | | Storage | SQLite with one immutable row per sample, never updated | a run you can rerun is a run you can compare, and overwriting destroys the only history you have | | Providers | Documented APIs only, one adapter per provider, keys in the environment | driving a web interface to dodge the quota is both fragile and against the terms you agreed to | | Matching | Token-boundary matching against an explicit alias list | substring matching against a short brand name turns every run into noise | | Reporting | Counts, sample sizes and spread on every figure | a single number with no denominator is the thing this category is usually selling | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the CLI, the scheduler and the dashboard. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **API keys for at least two answer providers** · pay per call; the estimate in this build is what stops it surprising you - Why: One provider is one model's opinion, and the whole point of sampling is seeing how much the answer moves, which needs more than one source of movement. - Get it: Create keys through each provider's documented developer console and set a spend limit there as well. - Verify: One question returns a parsed answer with citations from each provider - [ ] **The alias list and the known false positives** · free - Why: The matcher is the difference between a real signal and a chart that responds to unrelated English, and the false positives have to be written down before the matcher exists. - Get it: List every spelling, misspelling and abbreviation of your product and competitors, plus words that would wrongly match. - Verify: Both lists are in the fixture file and the suite fails if a false positive matches - [ ] **A starting question set you did not generate from your own marketing** · free - Why: Questions written from your own positioning ask what you would like to be known for rather than what anyone actually asks, and the resulting figures flatter you. - Get it: Collect real questions from your support inbox, sales calls and search console queries. - Verify: At least twenty questions exist and their origin is recorded on each row - [ ] **The monthly ceiling and samples per question** · free - Why: These two numbers together determine both the cost and whether the output means anything, and setting them after the first bill means setting samples to one. - Get it: Decide the monthly ceiling first, then work out how many samples per question it affords at your question count. - Verify: Both figures are in the configuration and the estimator refuses a run that would cross the ceiling ## 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: - Scraping answer engine web interfaces. - Search rank tracking. - Continuous forum monitoring. - Content plans and draft generation. - the continuous Reddit and Hacker News scan and buyer-question clustering, which finds where buyers are already asking instead of guessing questions to feed a model - Google rank badges on matched threads, sourced from a paid search API - the writing-plan-to-draft loop that turns a gap into a dated 30-day plan and AI-ready page drafts, not just a dashboard number - weeks of trend history and a competitor 'what moved' digest; a single run is mostly noise - a hosted, shareable report link you can hand a client without exposing your own infra If one of those is essential to you, that is the reason to keep paying for CueScout, and the README should say so rather than pretend.
# Build brief · CueScout 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 a tracker that samples what answer engines say about your product and reports the answer as counts with their spread to replace CueScout. 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 There is no ranking here to measure, so do not build an interface that looks like one. Google's own search documentation states that there are no additional requirements to appear in AI Overviews or AI Mode and no special optimisations, no machine readable files and no special structured data that make it happen. The same documentation says that sites appearing in those features are folded into overall search traffic in Search Console rather than reported separately. So the platform publishes neither a lever you can pull nor a figure you can check yourself against. What you can actually do is sample. Ask a question, record what came back, ask it again, and see how much the answer moves on its own. That means: every figure this app shows carries the number of samples behind it, each question is asked more than once per run, the run-to-run spread is computed and displayed next to any change, and a movement smaller than that spread is reported as no change rather than as a trend. Do not call any of it a rank, a position or a score out of a hundred, because each of those words implies a league table that the platforms do not publish. And use the documented APIs. Do not drive an answer engine's web interface with a browser to avoid paying for calls, whatever the plan's quota looks like. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a CLI plus a server-rendered dashboard · this is a scheduled job with a reading surface, not an application - Storage: SQLite with one immutable row per sample, never updated · a run you can rerun is a run you can compare, and overwriting destroys the only history you have - Providers: Documented APIs only, one adapter per provider, keys in the environment · driving a web interface to dodge the quota is both fragile and against the terms you agreed to - Matching: Token-boundary matching against an explicit alias list · substring matching against a short brand name turns every run into noise - Reporting: Counts, sample sizes and spread on every figure · a single number with no denominator is the thing this category is usually selling ### Data model (create this before Phase 1) - products(id, name, aliases_json, domains_json, description) · aliases are explicit, including the awkward ones and the misspellings - competitors(id, product_id, name, aliases_json, domains_json) - questions(id, product_id, text, origin, added_at, retired_at) · origin is generated, supplied or observed, and retired questions stay for history - runs(id, product_id, started_at, finished_at, samples_per_question, provider_set_json, cost_minor) - samples(id, run_id, question_id, provider, attempt, model_id, raw_response, finish_reason, error_text, latency_ms, created_at) · never updated, never deleted - mentions(sample_id, entity_kind, entity_id, count, first_offset) · entity_kind is product or competitor - citations(sample_id, url_raw, host, path_canonical, is_owned) · normalised at write time and the raw URL kept beside it - The primary key of the truth in this app is the sample, not the run. A run is an aggregation over samples and can be recomputed from them at any time. ### Phase 1 · An identity you can match without lying to yourself Build: Define the product with a name, an explicit alias list, owned domains and up to five competitors with the same shape. Match case-insensitively on word boundaries, handling punctuation, hyphens and possessives. Build a fixture file of true and false matches before writing the matcher. Trap: Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. Done when: A fixture set of at least thirty positive and thirty negative strings passes, including the brand name embedded inside a longer word and the brand name as a possessive. An alias list with an entry that is a substring of a common English word is flagged at configuration time with the word named. Competitor matching uses the same code path as product matching, asserted by review. Matching is case-insensitive and unaffected by surrounding punctuation, tested. Do not build yet: No provider calls, sampling, scoring or reporting yet. ### Phase 2 · A run is many samples, not one answer Build: For each question and provider, send the configured number of attempts, storing each response as its own row with the model identifier, finish reason, latency and any error text. Retry transport failures with backoff and record the retry as a separate attempt. A failed attempt is a stored row, not a gap. Trap: Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. Done when: A run stores one row per question, provider and attempt, and the count matches the configured samples per question exactly, asserted in the suite. A provider error stores a row with its error text and does not reduce the attempt count silently. No code path updates or deletes a sample row, verified by review and by a database trigger that refuses updates. Rerunning the aggregation over stored samples reproduces the previous run figures exactly. Do not build yet: No citation normalisation, scoring or reporting yet. ### Phase 3 · Citations normalised once, kept raw forever Build: Extract every cited URL from each sample. Store the raw string, then a normalised host and canonical path with tracking parameters stripped, trailing slashes resolved and known redirector wrappers unwrapped. Mark citations whose host is in the product's owned domains. Trap: Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. Done when: Every citation row holds both the raw URL and the derived host and path, asserted on a real run. Recomputing normalisation over the whole table produces the same results and changes no raw values, tested. Two URLs differing only by tracking parameters or a trailing slash normalise to the same value, and two genuinely different pages do not, tested with fixtures. Owned-domain marking uses the configured domain list and handles subdomains explicitly rather than by substring. Do not build yet: No scoring, trend reporting or scheduling yet. ### Phase 4 · Change, only when it is bigger than the noise Build: Compute per run: the share of samples mentioning the product, the share citing an owned domain, and the same for each competitor, each with its sample count. Compute the within-run spread across attempts of the same question. When comparing two runs, display the difference beside the spread and label anything smaller than it as within noise. Trap: Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. Done when: Every displayed rate shows its numerator and denominator, checked by review of each view. The within-run spread is computed from repeated attempts of the same question and stored on the run. A between-run difference smaller than the spread renders as within noise rather than as an arrow, tested with constructed data. No view anywhere displays a combined score without the component counts beside it. Do not build yet: No scheduling, cost ceiling, export or sharing yet. ### Phase 5 · Scheduling, spend and a report you can hand over Build: Add a scheduled run with a lock so two do not overlap. Estimate the cost of a run before it starts from the configured samples and providers, refuse to start if it would cross the monthly ceiling, and record actual spend per run. Export samples, mentions and citations as CSV, and render a static HTML report for a date range that opens without this application. Trap: A scheduler with no lock and a retrying wrapper will start a second run while the first is still going, doubling the day's spend and producing two partial runs that both look like the day's answer. Take a lock, record the run as started before the first call, and make a crashed run resumable rather than restartable. Done when: Two schedulers starting at once produce one run, tested by racing them. A run whose estimated cost would cross the monthly ceiling is refused before any call is made, with the estimate and the remaining budget shown. A crashed run resumes from the samples already stored rather than starting again, tested by killing it midway. The static report opens from the filesystem, shows every figure with its counts, and names the providers and model identifiers used. Do not build yet: Nothing after this. ### Out of scope (and why) - Scraping answer engine web interfaces to avoid API charges, which breaks the terms you agreed to and produces data you cannot defend to anyone. - Search rank tracking, which needs a paid search API and is a different measurement with its own long history of caveats. - Continuous forum monitoring for buyer questions, which is a separate ingestion product with its own platform terms and rate limits to read first. - Turning a gap into a dated content plan and drafts, which is the orchestration half of the paid product and deserves its own build rather than a tab. ### README must contain - That the platforms publish no ranking for these features and no way to verify your own appearance, with the documentation quoted and dated - That every figure is a sample with a denominator, and how many samples per question a run takes - How the within-run spread is computed and why differences smaller than it are reported as no change - That sample rows are immutable and how to recompute every derived figure from them - The estimated cost of one run at the configured settings, and what happens at the monthly ceiling
# Agent instructions · CueScout indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript, a CLI plus a server-rendered dashboard, SQLite with one immutable row per sample, never updated, Documented APIs only, one adapter per provider, keys in the environment, Token-boundary matching against an explicit alias list, Counts, sample sizes and spread on every figure. 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 - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them.
# Build plan · CueScout A visibility sampler whose hard problems are matching a brand name without matching every word that contains it, treating a model answer as one noisy sample rather than a measurement, and reporting change only when it exceeds the variance the same question produces on its own. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An identity you can match without lying to yourself Brand and competitor detection works on token boundaries against an explicit alias list, and is tested against the cases that break it. ### Steps 1. Write the fixture file before the matcher The negatives are the part that matters and they are easy to invent afterwards to fit. Files: `fixtures/matching.json`, `src/match/index.ts` 2. Match on token boundaries, not substrings Including hyphens, possessives and trailing punctuation. Files: `src/match/tokens.ts`, `tests/match.test.ts` 3. Warn at configuration time about dangerous aliases A two-letter alias will match everything and nobody will notice for a month. Files: `src/config/product.ts` ### Done when - [ ] Thirty positive and thirty negative fixtures pass - [ ] A brand name inside a longer word does not match - [ ] Competitors use the same matcher as the product - [ ] A substring-risky alias is flagged at configuration time ### Watch out - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. ## Phase 2 · A run is many samples, not one answer Every question is asked several times per provider per run, and every response is stored immutably with its errors. ### Steps 1. One row per attempt, including failures A gap in the data looks identical to a zero and means something else. Files: `src/run/sample.ts`, `src/db/schema.sql` 2. Refuse updates to samples at the database level The immutability rule enforced somewhere it cannot be forgotten. Files: `src/db/schema.sql`, `tests/immutable.test.ts` 3. Retry transport failures, record each attempt A retry is not the same event as the original call. Files: `src/providers/retry.ts` ### Done when - [ ] Attempt count matches the configured samples exactly - [ ] Provider errors are stored rows with their text - [ ] Sample updates are refused by the database - [ ] Aggregation is reproducible from stored samples ### Watch out - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. ## Phase 3 · Citations normalised once, kept raw forever Cited URLs are reduced to a comparable form without losing what the provider actually returned. ### Steps 1. Store the raw URL beside the derived fields Normalisation rules change and history should not. Files: `src/citations/store.ts` 2. Strip tracking parameters and unwrap redirectors With a fixture file of real examples from your own runs. Files: `src/citations/normalise.ts`, `fixtures/urls.json` 3. Make renormalisation a single command So a fix to the rules applies to years of data. Files: `src/cli/renormalise.ts`, `tests/renormalise.test.ts` ### Done when - [ ] Raw and derived values both stored - [ ] Renormalisation is idempotent and leaves raw untouched - [ ] Tracking-parameter variants collapse, different pages do not - [ ] Owned-domain marking handles subdomains explicitly ### Watch out - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. ## Phase 4 · Change, only when it is bigger than the noise Figures are reported with their sample size and spread, and a difference inside the spread is reported as no change. ### Steps 1. Compute rates with their counts attached The denominator is the part that stops this being astrology. Files: `src/report/rates.ts`, `tests/rates.test.ts` 2. Compute the within-run spread from repeated attempts Your own error bar, measured rather than assumed. Files: `src/report/spread.ts` 3. Label sub-spread differences as within noise Not an arrow, not a colour, a sentence. Files: `src/views/compare.tsx` ### Done when - [ ] Every rate shows numerator and denominator - [ ] Within-run spread is stored on the run - [ ] A sub-spread difference renders as within noise - [ ] No combined score appears without its components ### Watch out - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. ## Not in this build - Scraping answer engine web interfaces. - Search rank tracking. - Continuous forum monitoring. - Content plans and draft generation. ## After v1, if you want it - A second matcher pass that records the sentence around each mention, so you can see whether being mentioned was good - A question retirement workflow, since a question nobody asks any more still costs a sample every run - A per-question stability figure, which tells you which questions are worth tracking at all
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Products, questions, runs and every sample ever taken. DATABASE_PATH=./data/visibility.db # Required · secret. First answer provider. Set a spend limit in their console too. PROVIDER_A_KEY=pplx-...redacted # Required · secret. Second answer provider. Two sources of movement, not one. PROVIDER_B_KEY=sk-...redacted # Required. Attempts per question per provider per run. Below three the spread is meaningless. SAMPLES_PER_QUESTION=5 # Required. A run is refused before any call if its estimate would cross this. MONTHLY_COST_CEILING=20.00 # Required. Stops two scheduled runs overlapping and doubling the bill. RUN_LOCK_PATH=./data/run.lock # Required. Used to mark owned citations. Subdomains are explicit, not inferred. OWNED_DOMAINS=example.com,docs.example.com
# CueScout · product brief ## Problem The visibility-check loop is the weekend-buildable half: generate buyer questions, run them through Perplexity and ChatGPT, detect brand and competitor mentions, normalize citations, and score a GEO number. That much is close to what a $49/mo plan actually ships, since Basic only covers one engine. What does not fit in a prompt is the continuous Reddit and Hacker News scan that mines buyer questions from real threads instead of guessing them, the writing-plan-to-draft loop that turns a score into dated work, and weeks of trend history without which one run tells you almost nothing. ## Product outcome A tracker that says how often two answer engines mentioned you out of how many tries, how much that number moves on its own, and nothing else, with a static report you can hand to someone without giving them a login. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Perplexity API key - OpenAI API key - durable per-run storage - a scheduler for repeat runs ## Explicit non-goals for v1 - Scraping answer engine web interfaces. - Search rank tracking. - Continuous forum monitoring. - Content plans and draft generation. - the continuous Reddit and Hacker News scan and buyer-question clustering, which finds where buyers are already asking instead of guessing questions to feed a model - Google rank badges on matched threads, sourced from a paid search API - the writing-plan-to-draft loop that turns a gap into a dated 30-day plan and AI-ready page drafts, not just a dashboard number - weeks of trend history and a competitor 'what moved' digest; a single run is mostly noise - a hosted, shareable report link you can hand a client without exposing your own infra ## Success criteria - The negative matching fixtures are confirmed to fail the build when they match - Sample rows are confirmed immutable at the database level - Every displayed rate is confirmed to show its counts - A sub-spread difference is confirmed to render as within noise - A run over the monthly ceiling is confirmed refused before any call
# Build brief · CueScout 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 a tracker that samples what answer engines say about your product and reports the answer as counts with their spread to replace CueScout. 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 There is no ranking here to measure, so do not build an interface that looks like one. Google's own search documentation states that there are no additional requirements to appear in AI Overviews or AI Mode and no special optimisations, no machine readable files and no special structured data that make it happen. The same documentation says that sites appearing in those features are folded into overall search traffic in Search Console rather than reported separately. So the platform publishes neither a lever you can pull nor a figure you can check yourself against. What you can actually do is sample. Ask a question, record what came back, ask it again, and see how much the answer moves on its own. That means: every figure this app shows carries the number of samples behind it, each question is asked more than once per run, the run-to-run spread is computed and displayed next to any change, and a movement smaller than that spread is reported as no change rather than as a trend. Do not call any of it a rank, a position or a score out of a hundred, because each of those words implies a league table that the platforms do not publish. And use the documented APIs. Do not drive an answer engine's web interface with a browser to avoid paying for calls, whatever the plan's quota looks like. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a CLI plus a server-rendered dashboard · this is a scheduled job with a reading surface, not an application - Storage: SQLite with one immutable row per sample, never updated · a run you can rerun is a run you can compare, and overwriting destroys the only history you have - Providers: Documented APIs only, one adapter per provider, keys in the environment · driving a web interface to dodge the quota is both fragile and against the terms you agreed to - Matching: Token-boundary matching against an explicit alias list · substring matching against a short brand name turns every run into noise - Reporting: Counts, sample sizes and spread on every figure · a single number with no denominator is the thing this category is usually selling ### Data model (create this before Phase 1) - products(id, name, aliases_json, domains_json, description) · aliases are explicit, including the awkward ones and the misspellings - competitors(id, product_id, name, aliases_json, domains_json) - questions(id, product_id, text, origin, added_at, retired_at) · origin is generated, supplied or observed, and retired questions stay for history - runs(id, product_id, started_at, finished_at, samples_per_question, provider_set_json, cost_minor) - samples(id, run_id, question_id, provider, attempt, model_id, raw_response, finish_reason, error_text, latency_ms, created_at) · never updated, never deleted - mentions(sample_id, entity_kind, entity_id, count, first_offset) · entity_kind is product or competitor - citations(sample_id, url_raw, host, path_canonical, is_owned) · normalised at write time and the raw URL kept beside it - The primary key of the truth in this app is the sample, not the run. A run is an aggregation over samples and can be recomputed from them at any time. ### Phase 1 · An identity you can match without lying to yourself Build: Define the product with a name, an explicit alias list, owned domains and up to five competitors with the same shape. Match case-insensitively on word boundaries, handling punctuation, hyphens and possessives. Build a fixture file of true and false matches before writing the matcher. Trap: Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. Done when: A fixture set of at least thirty positive and thirty negative strings passes, including the brand name embedded inside a longer word and the brand name as a possessive. An alias list with an entry that is a substring of a common English word is flagged at configuration time with the word named. Competitor matching uses the same code path as product matching, asserted by review. Matching is case-insensitive and unaffected by surrounding punctuation, tested. Do not build yet: No provider calls, sampling, scoring or reporting yet. ### Phase 2 · A run is many samples, not one answer Build: For each question and provider, send the configured number of attempts, storing each response as its own row with the model identifier, finish reason, latency and any error text. Retry transport failures with backoff and record the retry as a separate attempt. A failed attempt is a stored row, not a gap. Trap: Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. Done when: A run stores one row per question, provider and attempt, and the count matches the configured samples per question exactly, asserted in the suite. A provider error stores a row with its error text and does not reduce the attempt count silently. No code path updates or deletes a sample row, verified by review and by a database trigger that refuses updates. Rerunning the aggregation over stored samples reproduces the previous run figures exactly. Do not build yet: No citation normalisation, scoring or reporting yet. ### Phase 3 · Citations normalised once, kept raw forever Build: Extract every cited URL from each sample. Store the raw string, then a normalised host and canonical path with tracking parameters stripped, trailing slashes resolved and known redirector wrappers unwrapped. Mark citations whose host is in the product's owned domains. Trap: Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. Done when: Every citation row holds both the raw URL and the derived host and path, asserted on a real run. Recomputing normalisation over the whole table produces the same results and changes no raw values, tested. Two URLs differing only by tracking parameters or a trailing slash normalise to the same value, and two genuinely different pages do not, tested with fixtures. Owned-domain marking uses the configured domain list and handles subdomains explicitly rather than by substring. Do not build yet: No scoring, trend reporting or scheduling yet. ### Phase 4 · Change, only when it is bigger than the noise Build: Compute per run: the share of samples mentioning the product, the share citing an owned domain, and the same for each competitor, each with its sample count. Compute the within-run spread across attempts of the same question. When comparing two runs, display the difference beside the spread and label anything smaller than it as within noise. Trap: Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. Done when: Every displayed rate shows its numerator and denominator, checked by review of each view. The within-run spread is computed from repeated attempts of the same question and stored on the run. A between-run difference smaller than the spread renders as within noise rather than as an arrow, tested with constructed data. No view anywhere displays a combined score without the component counts beside it. Do not build yet: No scheduling, cost ceiling, export or sharing yet. ### Phase 5 · Scheduling, spend and a report you can hand over Build: Add a scheduled run with a lock so two do not overlap. Estimate the cost of a run before it starts from the configured samples and providers, refuse to start if it would cross the monthly ceiling, and record actual spend per run. Export samples, mentions and citations as CSV, and render a static HTML report for a date range that opens without this application. Trap: A scheduler with no lock and a retrying wrapper will start a second run while the first is still going, doubling the day's spend and producing two partial runs that both look like the day's answer. Take a lock, record the run as started before the first call, and make a crashed run resumable rather than restartable. Done when: Two schedulers starting at once produce one run, tested by racing them. A run whose estimated cost would cross the monthly ceiling is refused before any call is made, with the estimate and the remaining budget shown. A crashed run resumes from the samples already stored rather than starting again, tested by killing it midway. The static report opens from the filesystem, shows every figure with its counts, and names the providers and model identifiers used. Do not build yet: Nothing after this. ### Out of scope (and why) - Scraping answer engine web interfaces to avoid API charges, which breaks the terms you agreed to and produces data you cannot defend to anyone. - Search rank tracking, which needs a paid search API and is a different measurement with its own long history of caveats. - Continuous forum monitoring for buyer questions, which is a separate ingestion product with its own platform terms and rate limits to read first. - Turning a gap into a dated content plan and drafts, which is the orchestration half of the paid product and deserves its own build rather than a tab. ### README must contain - That the platforms publish no ranking for these features and no way to verify your own appearance, with the documentation quoted and dated - That every figure is a sample with a denominator, and how many samples per question a run takes - How the within-run spread is computed and why differences smaller than it are reported as no change - That sample rows are immutable and how to recompute every derived figure from them - The estimated cost of one run at the configured settings, and what happens at the monthly ceiling
# Architecture · CueScout ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript, a CLI plus a server-rendered dashboard | this is a scheduled job with a reading surface, not an application | | Storage | SQLite with one immutable row per sample, never updated | a run you can rerun is a run you can compare, and overwriting destroys the only history you have | | Providers | Documented APIs only, one adapter per provider, keys in the environment | driving a web interface to dodge the quota is both fragile and against the terms you agreed to | | Matching | Token-boundary matching against an explicit alias list | substring matching against a short brand name turns every run into noise | | Reporting | Counts, sample sizes and spread on every figure | a single number with no denominator is the thing this category is usually selling | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Identity | Aliases, competitors, owned domains and matching. | Any matcher that passes the fixture file, including the negatives. | | Sampling | Provider adapters, attempts and immutable rows. | Any provider with a documented API; never a browser driving a web interface. | | Citations | Raw storage and recomputable normalisation. | Any rules, provided raw URLs survive and recomputation is one command. | | Reporting | Rates with counts, spread and change labelling. | Any presentation that keeps denominators visible. | | Schedule | Locking, cost estimation and resumption. | Any scheduler, provided the lock and the pre-flight estimate stay. | ## 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 · Products, questions, runs and every sample ever taken. - `PROVIDER_A_KEY` · required, secret · First answer provider. Set a spend limit in their console too. - `PROVIDER_B_KEY` · required, secret · Second answer provider. Two sources of movement, not one. - `SAMPLES_PER_QUESTION` · required · Attempts per question per provider per run. Below three the spread is meaningless. - `MONTHLY_COST_CEILING` · required · A run is refused before any call if its estimate would cross this. - `RUN_LOCK_PATH` · required · Stops two scheduled runs overlapping and doubling the bill. - `OWNED_DOMAINS` · required · Used to mark owned citations. Subdomains are explicit, not inferred. ## 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 · CueScout product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript, a CLI plus a server-rendered dashboard, SQLite with one immutable row per sample, never updated, Documented APIs only, one adapter per provider, keys in the environment, Token-boundary matching against an explicit alias list, Counts, sample sizes and spread on every figure. - 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 - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. - A scheduler with no lock and a retrying wrapper will start a second run while the first is still going, doubling the day's spend and producing two partial runs that both look like the day's answer. Take a lock, record the run as started before the first call, and make a crashed run resumable rather than restartable.
# Delivery milestones · CueScout Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An identity you can match without lying to yourself Brand and competitor detection works on token boundaries against an explicit alias list, and is tested against the cases that break it. ### Steps 1. Write the fixture file before the matcher The negatives are the part that matters and they are easy to invent afterwards to fit. Files: `fixtures/matching.json`, `src/match/index.ts` 2. Match on token boundaries, not substrings Including hyphens, possessives and trailing punctuation. Files: `src/match/tokens.ts`, `tests/match.test.ts` 3. Warn at configuration time about dangerous aliases A two-letter alias will match everything and nobody will notice for a month. Files: `src/config/product.ts` ### Done when - [ ] Thirty positive and thirty negative fixtures pass - [ ] A brand name inside a longer word does not match - [ ] Competitors use the same matcher as the product - [ ] A substring-risky alias is flagged at configuration time ### Watch out - Matching a brand name as a substring means a product called Cue is mentioned by every answer containing the word rescue, and the resulting graph goes up and to the right for reasons that have nothing to do with your product. Match on token boundaries, keep an explicit list of known false positives in the fixtures, and fail the build when one of them matches. ## M2 · A run is many samples, not one answer Every question is asked several times per provider per run, and every response is stored immutably with its errors. ### Steps 1. One row per attempt, including failures A gap in the data looks identical to a zero and means something else. Files: `src/run/sample.ts`, `src/db/schema.sql` 2. Refuse updates to samples at the database level The immutability rule enforced somewhere it cannot be forgotten. Files: `src/db/schema.sql`, `tests/immutable.test.ts` 3. Retry transport failures, record each attempt A retry is not the same event as the original call. Files: `src/providers/retry.ts` ### Done when - [ ] Attempt count matches the configured samples exactly - [ ] Provider errors are stored rows with their text - [ ] Sample updates are refused by the database - [ ] Aggregation is reproducible from stored samples ### Watch out - Asking each question once per run and plotting the result produces a chart that moves every day because the models are sampling, not because anything changed, and you will spend a week explaining a fluctuation to a client that was never there. Ask several times per run, store each attempt, and compute the within-run spread before you display any between-run difference. ## M3 · Citations normalised once, kept raw forever Cited URLs are reduced to a comparable form without losing what the provider actually returned. ### Steps 1. Store the raw URL beside the derived fields Normalisation rules change and history should not. Files: `src/citations/store.ts` 2. Strip tracking parameters and unwrap redirectors With a fixture file of real examples from your own runs. Files: `src/citations/normalise.ts`, `fixtures/urls.json` 3. Make renormalisation a single command So a fix to the rules applies to years of data. Files: `src/cli/renormalise.ts`, `tests/renormalise.test.ts` ### Done when - [ ] Raw and derived values both stored - [ ] Renormalisation is idempotent and leaves raw untouched - [ ] Tracking-parameter variants collapse, different pages do not - [ ] Owned-domain marking handles subdomains explicitly ### Watch out - Normalising in place and discarding the raw URL means that when your canonicalisation turns out to merge two different pages, every historical citation has already been rewritten and there is nothing to recompute from. Keep the raw string on the row, derive the normalised fields, and be able to rebuild them all with one command. ## M4 · Change, only when it is bigger than the noise Figures are reported with their sample size and spread, and a difference inside the spread is reported as no change. ### Steps 1. Compute rates with their counts attached The denominator is the part that stops this being astrology. Files: `src/report/rates.ts`, `tests/rates.test.ts` 2. Compute the within-run spread from repeated attempts Your own error bar, measured rather than assumed. Files: `src/report/spread.ts` 3. Label sub-spread differences as within noise Not an arrow, not a colour, a sentence. Files: `src/views/compare.tsx` ### Done when - [ ] Every rate shows numerator and denominator - [ ] Within-run spread is stored on the run - [ ] A sub-spread difference renders as within noise - [ ] No combined score appears without its components ### Watch out - Multiplying two rates together into one headline number is the move that makes this category look like analytics, and it hides the fact that a mention rate of two out of thirty and a citation rate of one out of two produced it. Show the counts that made every figure, and refuse to render the combined number anywhere without them. ## M5 · Scheduling, spend and a report you can hand over (production only) Runs happen on a schedule inside a cost ceiling, and the output leaves as files rather than as a login. ### Steps 1. Lock the scheduled run Two overlapping runs cost double and answer nothing. Files: `src/schedule/lock.ts`, `tests/lock.test.ts` 2. Estimate before spending, refuse at the ceiling The estimate is computed from samples times providers times questions. Files: `src/cost/estimate.ts` 3. Emit a static report and a CSV export Something you can send, not something you have to log into. Files: `src/report/static.ts`, `src/cli/export.ts` ### Done when - [ ] Racing schedulers produce one run - [ ] A run over the ceiling is refused before any call - [ ] A crashed run resumes rather than restarts - [ ] The static report opens from the filesystem with counts shown ### Watch out - A scheduler with no lock and a retrying wrapper will start a second run while the first is still going, doubling the day's spend and producing two partial runs that both look like the day's answer. Take a lock, record the run as started before the first call, and make a crashed run resumable rather than restartable.
# Operations · CueScout ## Backup The database, which is every sample ever taken and cannot be recreated because the providers will not answer yesterday's question the same way. Everything else is derived. ## Restore Restore and rerun the aggregation, then compare the recomputed run figures against what the dashboard showed before. They should match exactly, because aggregation is a pure function of the samples. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Spend against the ceiling, the provider error rate per run, and the within-run spread over time. A spread that grows means the answers are getting less stable, which is itself the most interesting number in the app. ## Incident checklist The failure mode here is a figure someone acted on. Immutable samples with raw responses are what let you show exactly what was asked, what came back and how many times, months later. 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 - [ ] The negative matching fixtures are confirmed to fail the build when they match - [ ] Sample rows are confirmed immutable at the database level - [ ] Every displayed rate is confirmed to show its counts - [ ] A sub-spread difference is confirmed to render as within noise - [ ] A run over the monthly ceiling is confirmed refused before any call ## Launch constraint Do not market omitted CueScout 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. Products, questions, runs and every sample ever taken. DATABASE_PATH=./data/visibility.db # Required · secret. First answer provider. Set a spend limit in their console too. PROVIDER_A_KEY=pplx-...redacted # Required · secret. Second answer provider. Two sources of movement, not one. PROVIDER_B_KEY=sk-...redacted # Required. Attempts per question per provider per run. Below three the spread is meaningless. SAMPLES_PER_QUESTION=5 # Required. A run is refused before any call if its estimate would cross this. MONTHLY_COST_CEILING=20.00 # Required. Stops two scheduled runs overlapping and doubling the bill. RUN_LOCK_PATH=./data/run.lock # Required. Used to mark owned citations. Subdomains are explicit, not inferred. OWNED_DOMAINS=example.com,docs.example.com
$ choose a build depth, inspect the files, then open the complete pack in your agent
They're paying for the parts that don't fit in a prompt: a script that runs every day for months without babysitting, buyer questions mined from real Reddit and Hacker News threads instead of guessed ones, a writing plan and AI-ready drafts wired to the same gaps the scores found, and a link they can hand a client. None of that is a moat a bigger company can't cross; it's the upkeep most people quit paying attention to by week three.
xthe continuous Reddit and Hacker News scan and buyer-question clustering, which finds where buyers are already asking instead of guessing questions to feed a model
xGoogle rank badges on matched threads, sourced from a paid search API
xthe writing-plan-to-draft loop that turns a gap into a dated 30-day plan and AI-ready page drafts, not just a dashboard number
xweeks of trend history and a competitor 'what moved' digest; a single run is mostly noise
xa hosted, shareable report link you can hand a client without exposing your own infra
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
CueScout pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| basic | $49/workspace | $39.17/workspace | 1 product; 450 checks/month (15/day); Perplexity only; scans every 24 hours; 3 plan regenerations/week; 20 drafts/month; 1 report. |
| growth | $99/workspace | $79.17/workspace | 3 products; 1,200 checks/month (40/day); ChatGPT and Perplexity; scans every 12 hours/product; 10 plan regenerations/week; 80 drafts/month; 3 reports. |
| agency | $249/workspace | $199.17/workspace | 10 products; 3,600 checks/month; ChatGPT and Perplexity; scans every 6 hours/product; 30 plan regenerations/week; 240 drafts/month; 10 reports. |
| founder pack | custom | — | 30 days of Basic-level access for 1 product. |
free tierno free tier
billingmonthly, quarterly, or annual; annual plans are billed upfront
hidden costsMore than 10 products requires custom pricing.
verified 2026-08-14 · source ↗
Vibecode CueScout
Kinda. The core of CueScout is buildable in a weekend with the prompt on this page, but there are real gaps: the continuous Reddit and Hacker News scan and buyer-question clustering, which finds where buyers are already asking instead of guessing questions to feed a model, Google rank badges on matched threads, sourced from a paid search API. Read the honest list above before committing.
How much does CueScout cost?
CueScout costs about $49/month (Basic, checked 2026-08-08), which is $588 per year.
What do I lose by replacing CueScout?
Honestly: the continuous Reddit and Hacker News scan and buyer-question clustering, which finds where buyers are already asking instead of guessing questions to feed a model; Google rank badges on matched threads, sourced from a paid search API; the writing-plan-to-draft loop that turns a gap into a dated 30-day plan and AI-ready page drafts, not just a dashboard number; weeks of trend history and a competitor 'what moved' digest; a single run is mostly noise; a hosted, shareable report link you can hand a client without exposing your own infra. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to CueScout?
Yes: Elmo (Covers the visibility-check half well; it has no Reddit/HN buyer-thread mining and no writing-plan or draft generation on top of the scores.) The prompt is for when you want it exactly your way.