← AppFollowCONTENT HISTORY

Update to AppFollow

Snapshot Oct 31, 2026 · 01:17 UTC · version 1.2.2

Collection source: downloaded plugin package.

WHAT CHANGED

First saved snapshot

No earlier snapshot is available to establish a change.

Save AppFollow and check future changes in Following.

Up to 3 plugins free · no card required.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "Find an app's real competitors in one region on one or both platforms, add the chosen ones to tracking, and build a competitive audit — share of search across live store results, newly discovered competitors, category position, and 30-day rating velocity with a star breakdown. Emits a structured findings.json plus a self-contained HTML report. Trigger on \"who are our competitors\", \"competitor analysis\", \"competitive audit\", \"who outranks us in this country\", \"compare us against a named rival\", \"add competitors to tracking\", \"/competitors\".\n",
  "included_files": [
    {
      "relative_path": "references/report-template.html",
      "size_in_bytes": 6116
    }
  ],
  "name": "competitors",
  "skill_md_contents": "---\nname: competitors\ndescription: >\n  Find an app's real competitors in one region on one or both platforms, add the chosen ones to\n  tracking, and build a competitive audit — share of search across live store results, newly\n  discovered competitors, category position, and 30-day rating velocity with a star breakdown.\n  Emits a structured findings.json plus a self-contained HTML report. Trigger on \"who are our\n  competitors\", \"competitor analysis\", \"competitive audit\", \"who outranks us in this country\",\n  \"compare us against a named rival\", \"add competitors to tracking\", \"/competitors\".\n---\n\n# competitors\n\nRead `using-appfollow` first (session opening, write ritual, error codes).\n\nThis skill answers one question — *who are we actually competing with in this market, and how do we\nstand against them* — using only data the MCP can produce. Everything below was verified against the\nlive API; the constraints in **Verified limits** are not hypothetical and must not be designed around\noptimistically.\n\n## Inputs\n\nCollected interactively in step 1. Nothing is assumed.\n\n- **app** — name, store URL, or `ext_id`. A store URL is fine: parse the id out of it (`/id<digits>` for\n  App Store, `?id=<package>` for Google Play). The ASO search endpoints take free-text terms only, never\n  a URL or an id.\n- **platforms** — `ios`, `android`, or both. Maps to `store` + `device`: iOS → `as` + `iphone`,\n  Android → `gp` + `android`.\n- **region** — exactly **one** two-letter country code. The skill does not proceed with several.\n  One region is a hard requirement, not a default: results differ enough between markets that a\n  blended answer is meaningless.\n\n## Flow\n\n### 0. Session\n\n`whoami` (see `using-appfollow`).\n\n### 1. Scope\n\nAsk for app, platforms and region in one turn. Offer sensible defaults the user can accept in a word,\nbut do not guess the region — require it.\n\nThen state plainly, before starting:\n\n- Which platforms will run (each platform is an independent sweep and doubles the work).\n- That the audit needs the target app **tracked in the account**; competitor discovery does not.\n\n### 2. Resolve the target app\n\n`list_collections` → `list_apps` on each collection until the app is found. Record `ext_id`, `store`,\nthe collection `apps_id`, and the stored `country`.\n\n- **Found** → continue.\n- **Not found** → discovery (step 3) still works, because the store-search endpoints are not\n  account-scoped. Say so, run discovery, and offer to add the target app itself in step 5. The audit\n  sections that need tracking are marked unavailable until it is added.\n\nIf the app is tracked with a `country` different from the requested region, say so — it does not block\nanything (the region is passed per call), but it explains storefront differences in titles and ratings.\n\n### 3. Discovery sweep (the engine of this skill)\n\nPer platform, in the chosen country:\n\n1. **Seed the term set.** `aso_search_suggestions` on the app's brand name plus 3–5 category\n   generics in the market's own language. Use what the store returns, never your own translation —\n   suggestions frequently name competitors inside the suggestion strings, which is signal you get without\n   having to ask for it.\n2. **Pick 8 terms**: 5 category generics + the app's own brand + 2 rival brand terms. Fewer than 6 makes\n   share of search noise; more than 10 rarely changes the ranking of candidates.\n3. **Sweep.** `aso_search_results(term, country, device)`. Each response is the full\n   ranked list with `ext_id`, `title`, `artist_name`, `rating_avg`, `genre`/`genre_id`.\n\nKeep every response: steps 3, 4 and audit sections 1–3 all read from this one sweep. Do not re-fetch.\n\n### 4. Selection\n\nRank candidates by appearances across the sweep first, average position second. Present **the top 3 per\nplatform** with, for each: appearances (`n/8`), best and average position, publisher, store rating.\n\nThen give the user a free-text field to name their own competitors instead of or in addition to yours.\nResolve each typed name with one `aso_search_results` call and confirm which app you matched\nbefore adding anything.\n\n**The per-platform lists will not be brand-symmetric, and that is correct.** Verified: Just Eat is one\n`ext_id` on the App Store but two separate packages on Google Play (`com.justeat.app.es` and\n`com.justeat.app.uk`), both ranking in the same Spanish results. Match by market, never by brand — a\nhead-to-head between a Spanish app and a British one is a wrong answer that looks right.\n\n### 5. Add to tracking (the only write path)\n\nFor each selected competitor, per platform: `add_app_preview` → show exactly what will be written →\n**explicit approval for that app** → `add_app` with identical args + token.\n\nConfirm each app individually, not the batch. Two reasons, both verified:\n\n- **Competitors consume My Apps slots.** State this plainly, **without a number**. No endpoint reports\n  the plan's slot limit, so \"N of M used\" is impossible; and the only available count is unreliable —\n  `list_collections.count_apps` disagreed with `list_apps` on a real collection (5 reported, 12 actual),\n  so a summed total would be a confident wrong answer. Counting properly means one `list_apps` per\n  collection, which on a large account is more work than the number is worth. Say that each competitor\n  takes a slot and that the account's remaining capacity is visible only in the dashboard.\n- **There is no remove tool anywhere in the MCP.** An app added by mistake keeps its slot until someone\n  deletes it in the AppFollow dashboard. Say this before the first write, not after.\n\nAfter the writes, call `list_apps` again and **report the country that actually landed**. Verified: a\nrequest with `locale: es` was stored as `country: gb`. Never assume the requested locale is what was saved.\n\n`add_app` requires an Advanced API / Custom plan and carries no feature gate, so a plan block can arrive\nas `AUTH_REQUIRED`, `VALIDATION_ERROR`, or `UPSTREAM_ERROR`. On any error, do not name a specific cause:\nreport that the add could not be completed, likely a plan limitation, and continue with whatever is\nalready tracked.\n\n### 6. Audit\n\nFive sections, in this order. Two of them are available the moment a competitor is added; one needs a\nparse cycle. Say which is which in the report rather than printing empty tables.\n\n#### 6.1 Share of search\n\nComputed from the step-3 sweep, no extra calls. Per app across the term set:\n\n- **coverage** — in how many of the N terms the app appears at all\n- **average position** where present\n- **top-3 rate** — share of terms where it sits at position ≤ 3\n\nRank the target and all competitors in one table. This is **unweighted**: every term counts the same,\nbecause no popularity score is used. Label it that way in the report — do not imply traffic weighting.\n\n#### 6.2 Newly discovered competitors\n\nDiff the `ext_id`s seen in the sweep against everything in `list_apps`. Output the apps that outrank the\ntarget on its own terms and are not tracked, with appearances and best position. No extra requests — the\ndata is already in hand. End with an offer to add them (step 5 ritual applies).\n\nThis is the section that repeatedly produces the surprise. Verified examples: a generic Opera \"Local\nNews\" app at #4 on a query the customer thought they owned; iFood (a Brazilian app) at #4 on two Spanish\ngenerics on Google Play.\n\n#### 6.3 Category position\n\n`get_rankings(ext_id, country, device)` per app. Report `genre_id`, `feed_type`, `pos`, and\nthe row `date`.\n\n- **Flag category mismatches.** Take the genre from the sweep's SERP card (`genre`, `genre_id`) or from\n  `list_apps`. When a competitor sits in a different category than the target, say so explicitly and\n  note that the two are ranking in different pools, so the positions are not comparable. Verified real\n  case: Glovo is in Lifestyle (6012) while its direct rivals are in Food & Drink (6023).\n- **An empty list usually means \"not charting\", not \"not ready\".** `get_rankings` returns positions for\n  apps added moments earlier — verified: two competitors added during a run both returned a rank dated\n  the previous day, while the target app, tracked since 2023, returned an empty list. So the default\n  reading of an empty result is that the app does not appear in that country's category chart at the\n  depth the store exposes. Report it as **not charting**, next to the rivals' positions, because that\n  contrast is the finding.\n- **Only hedge when freshness is genuinely in play.** If an app was added in this run *and* its rank is\n  empty *and* the other apps in the same call also came back empty, the cause may be a parse cycle\n  rather than the chart. Say the data may need time to collect, give an approximate date, and include\n  the exact command to re-run — but never present a pending note when other apps in the same batch\n  returned positions.\n\n#### 6.4 Average rating, last 30 days\n\n`get_ratings_history(collection_name, ext_id, store, from, to, countries, period, type)` per app.\n\n- `type: \"diff\"` — incremental, not cumulative. This is the whole point of the section: the cumulative\n  average barely moves (observed 4.706 → 4.704 over a month) while the incremental average swings\n  meaningfully (4.617 → 4.673 across eight weeks on the same app).\n- In `diff` mode, `avg_rating` is the mean of the **new** ratings in that period. Verified by arithmetic:\n  `(108·1 + 13·2 + 66·3 + 220·4 + 1996·5) / 2403 = 4.658`, matching the returned value exactly.\n- **Check the collection's countries before the first call.** The `countries` filter only accepts codes\n  configured on the collection. A collection set to `bd,all` rejected `us` with\n  `NOT_FOUND: \"Couldn't find any country for the collection\"` on every app in it, while `bd` succeeded\n  and omitting the parameter returned a global aggregate; a collection set to `all` accepted both `us`\n  and `es`. `list_collections` already returns each collection's `countries`, so compare the requested\n  region against it up front. On a mismatch, tell the user **before calling**: the region must be added\n  to the collection in the dashboard, and until then the choice is a global aggregate clearly labelled\n  as not region-specific, or no section at all. Never fire four calls just to collect four 404s.\n- `countries` must carry **exactly one** code, and you must loop one country per call. With several\n  codes the API returns a single merged series with no per-country field, so the breakdown is\n  unrecoverable. A single code does filter correctly — verified by control (`es` and `us` returned\n  different numbers for the same app).\n- `period: \"weekly\"` gives the shape as well as the total. The final row is often a partial period of one\n  or two days — exclude it from sums and report it separately.\n- **This works on day one.** Ratings history is backfilled on add: an app added the previous day\n  returned eight full weeks of history.\n\n#### 6.5 New ratings with star breakdown, last 30 days\n\nSame call as 6.4, no extra request. Report per app: total new ratings and the split `stars1`…`stars5`, both\nas counts and as shares.\n\nLead with the share of negatives (1–2★), not the total. Volume differences are usually just scale;\nthe share is what says whether the inflow is healthier or worse. Verified illustration: one app took\n5.7× more new ratings than a rival, but their negative shares were 5.2% and 5.0% — the interesting fact\nwas that relative to installed base the *smaller* app was collecting ratings faster.\n\nWhere the numbers support it, add the base-relative read: new ratings over the window divided by the\napp's cumulative rating count. It reverses naive conclusions often enough to be worth the two lines of\narithmetic — but it is **App Store only**: `rating_cnt` comes from the sweep's SERP card, and Google Play\nrows carry `rating_avg` without it. On Android, say the read is unavailable rather than substituting\na different denominator.\n\n**Watch for inorganic bursts before drawing any conclusion.** A real run produced weekly volumes of\n4, 1, 1, then 158 in one week (155 of them 5★), then 159 ratings in three days of which *all 159* were\nexactly 2★. Uniform single-value blocks after near-zero volume are not organic. When the shape looks\nlike that, flag it as needing verification and refuse to summarise the app's rating health from it —\nreporting a 97.6% five-star share off such a series would be worse than reporting nothing.\n\n### 7. Emit\n\nWrite `competitors-<app-slug>-<country>-<date>.html` from `references/report-template.html`, plus\n`findings.json` under `appfollow.toolkit.findings/v1` with `report_type: \"competitors\"`. On Claude Code,\npublish the HTML as an artifact; on Codex, write the files and state the paths. Tell the user their own\nconnected tracker agent can file the findings.\n\n**`scope` keeps the shape the shared schema already defines** — `type: \"app\"` with `apps` holding the\nsingle target, exactly as `schemas/findings.schema.json` describes it. Competitors go in their own\ntop-level `competitors` block, never inside `scope.apps`: the enum has no value for a target-plus-rivals\nreport, and smuggling rivals into `apps` would contradict that field's own description. Extending the\nenum is a separate contract change, not this skill's business.\n\n```json\n{\n  \"report_type\": \"competitors\",\n  \"scope\": { \"type\": \"app\", \"apps\": [ { \"store\": \"gp\", \"ext_id\": \"...\", \"title\": \"...\", \"country\": \"us\", \"device\": \"android\" } ] },\n  \"competitors\": [ { \"store\": \"gp\", \"ext_id\": \"...\", \"title\": \"...\", \"tracked_before_run\": false } ]\n}\n```\n\nEvery finding carries `evidence[]{source:\"mcp:<tool>\", args, observed, at}` so any number in the report\ncan be traced back to the call that produced it.\n\n## Verified limits\n\nState these when they bite; never promise around them.\n\n| Limit | Consequence |\n|---|---|\n| Untracked apps return `AUTH_REQUIRED` (403) on `get_reviews`, `list_keywords`, and an empty-body `VALIDATION_ERROR` (422) on `get_ratings_history` | Discovery works on any app; every audit section except 6.1 and 6.2 needs the app tracked |\n| No remove tool exists | Adds are irreversible from the agent; slots stay consumed |\n| No slot-limit endpoint, and `list_collections.count_apps` disagrees with `list_apps` (5 vs 12 on a real collection) | Warn about slots without a number; the real figure is dashboard-only |\n| `add_app` is plan-gated with no feature gate | A block can surface as `AUTH_REQUIRED`, `VALIDATION_ERROR` or `UPSTREAM_ERROR` — do not attribute a cause |\n| Stored `country` can differ from the requested `locale` | Always read back with `list_apps` |\n| An empty `get_rankings` list normally means the app is not in that country's chart — apps added minutes earlier returned positions, a 3-year-tracked app returned nothing | Report \"not charting\"; hedge on freshness only when the whole batch came back empty |\n| `get_ratings_history` accepts only countries configured on the collection — `bd,all` rejected `us` with `NOT_FOUND \"Couldn't find any country for the collection\"` | Compare the region against `list_collections.countries` before calling; on mismatch, tell the user it is a dashboard fix |\n| `countries` with multiple codes merges into one unlabelled series | One country per call, always |\n| Google Play returns `rating_avg: 0` for the #1 row on an exact brand-name query | Never read a rating from a brand-term row; take it from a generic term or from ratings history |\n| Google Play search rows carry no `rating_cnt` | The base-relative read in 6.5 is App Store only |\n| App Store SERP enriches only ~the first 12 rows; deeper rows are bare `ext_id` | On iOS, \"not in the top N\" is a claim about the named window only. Google Play returns full rows throughout, so Android discovery is more reliable |\n| Same brand ≠ same app across stores and markets | Match by market; one store may have one id where the other has one package per country |\n\n## Out of scope\n\nDo not offer these — no data source exists in the MCP:\n\n- **Installs, downloads, MAU, revenue, market share.** The stored ASO reports are integration-gated and\n  fail with `ASO Report Integration Not Found` on accounts without them; there is no other source.\n- **Competitor metadata beyond the SERP card.** Title, subtitle and genre are available; descriptions and\n  the App Store keyword field are not.\n- **Keyword-level head-to-head.** Deliberately excluded from this skill: it needs the account to already\n  track keywords for that exact (country, device) pair — empty in 3 of 5 markets checked — and a parse\n  cycle before a newly added competitor is scored.\n- **Apple Search Ads as a competitor's targeting list.** `aso_search_ads_recommendations` returned an\n  empty `keywords` array for every app tried, including the account's own.\n\n## Degrade path\n\nA section never fails the run. Skip it, name it in the report with the error code verbatim, keep going:\n\n- Store search fails for a term → drop that term, recompute share of search over the remaining set, and\n  say the denominator changed.\n- `get_rankings` empty → pending-with-date, per 6.3.\n- `get_ratings_history` fails for one app → report the others and mark that app's rows unavailable.\n- One platform fails entirely → ship the other platform's report rather than nothing.\n"
}

SHA-256 of public snapshot: 41581ab1c2b0950094c9f22ae72006274488d48e4d57971c9bd4e1651a9763e4