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