Makes an Expo app show Google AdMob ads that never interrupt a focused task: Google's consent and Apple's tracking prompt come first, every "may an ad appear now?" answer comes from pure, tested functions, testing builds only ever see Google's test ads, iOS and Android each get their own IDs, and two zero-dependency checkers prove it on the real code.
Rules that must hold
- Ads never interrupt a focused task. Banners only on the screens contracts/ads.json
bannerSlotsdeclares (browsing screens, never a form, editor, dialog, paywall or onboarding), and only while that screen is focused (useBannerSlot(screen, facts, useIsFocused()): stacks and tabs keep it mounted under pushed screens); interstitials only from the press handler that leaves a natural break the user just finished, never on mount, app start or during onboarding; rewarded ads only on the user's tap behind a clear "Watch an ad to ..." label; the AdsPort's show, initialize and preload calls only inad-moments.tsandad-gate.ts. Why: an interruption costs the user's trust, and Google's policies punish ads that surprise or invite accidental taps. - Every ad decision comes from
ad-policy.ts/rewarded-offer.ts(pure; numbers fromsrc/config/ad-pacing.ts, at or above contracts/ads.jsonpacingFloors, default 3 breaks before the first interstitial, then 3 minutes and 2 breaks between two), and every finished natural break is saved withrecordBreakwhere it finishes. Why: only pure rules can be tested one by one, the numbers are configuration, and a history that never counts a break allows exactly one interstitial. - Consent, then Apple's prompt, before any ad request, through the consent moment, and none at all
when ads are off. UMP refreshes at every launch; where Google's form is required, the app's optional
primer and then the form; once
canRequestAds, ATT throughConsentPort.requestTracking()(iOS only, while not-determined, the app active); thensetRequestConfiguration+initialize+ preload. Only after onboarding, online, not Premium, over a focused ad screen.AdsProvider(throughconsent-moment-flow.ts) is the only runner ofprepareAds; banners are allowed only after the ATT answer and a successfulinitialize, and the adapter mounts its native banner only after it. The ConsentPort comes fromcreateConsentPort(adsMode, () => createAdmobConsentAdapter(...)), so anADS_MODE=offbuild never calls UMP or ATT. Why: Google requires consent (EEA, UK, Switzerland, US states), Apple's guideline 5.1.2(i) requires the ATT answer before IDFA use, the SDK may preload at initialisation, and a UMP update is a network request that would fail the network audit of off builds. - Testing builds never see real ads; real IDs reach the runtime only in production + live builds.
resolveAdsModedecides: development and preview ->test(oroff), production ->live(oroff); the other pairs throw. IDs live only insrc/config/admob-ids.ts, read only byapp.config.ts;admobPluginOptionsalways gives both platforms an app ID (Google's sample unless live);adsExtrawrites units only in live builds; the template's placeholder IDs are noted and never reach a store build.expo.extrais embedded at build time, or replaced by the running EAS Update's manifest, soeas updateruns with the channel's environment and the app reads it throughreadAdsExtraForBinary(Application.applicationId), which turns ads off for a manifest made for another variant's app id. Why: real ads in a testing build risk the AdMob account; a missing app ID stops the app at launch. react-native-google-mobile-adsis exactly 17.2.0 and is imported only byadmob-ads-adapter.ts,admob-consent-adapter.tsand the test-onlyadmob-consent-debug-adapter.ts, with the classic create/load/show API (no v17 hooks or pools), and the adapters are created only insidecreateAdsPort/createConsentPortin the composition root. Why: an upgrade touches three files, pacing stays in pure code, and pools preload (on Android initialise) before consent.- The reward is granted only on
EARNED_REWARD, and the AdsPort reports aRewardedStatus('loading' | 'ready' | 'unavailable'), never a loaded flag.rewardedOffergives a perk the user waits on'loading'and'hidden'only when no ad can come, so a flow waiting on it moves on. Why: with a flag, "not loaded" means both "still loading" and "cannot come", and a waiting user is stranded. - Pause media around every fullscreen ad (
runFullscreenAdwith the oneMediaLifecycle), resume infinally, and make every show settle onCLOSED, onERROR(phaseshow) and on a rejectedshow(). Why: iOS does not background the app for these ads, so nothing else pauses audio or timers, and a show that never settles leaves media suspended and the handler stuck. - Ads are silent and their failures are values. Never an error, spinner or "please connect" for
ads; failures go to the error log; branch on
error.phasethenerror.reason, nevererror.code;initialize()returns a Result; the banner slot has zero height until an ad has loaded. Why: an ad is never what the user came for, and an escaped rejection is dropped silently in release builds. - The audience is declared and enforced, and tracking follows Apple's rules. contracts/ads.json
audience:general-> the declaredmaxAdContentRating(default PG), no child tags;child-directed-> G, both child tags, Android'sAD_IDpermission blocked, and no ATT at all (no tracking package, plugin, purpose text or adapter;tracking: null). General audience:expo-tracking-transparencyis imported only byexpo-tracking-consent-adapter.ts, handed to the consent adapter astracking; its plugin entry gets the source text and every language's catalog has it; the AdMob plugin gets nouserTrackingUsageDescription; the console publishes no IDFA explainer. Declined, restricted or unavailable tracking still serves ads, and nothing is gated on the answer. Why: the rating must match the console, apps for children may not send device data to third parties (App Review 1.3, Play Families), a missing purpose text crashes, and 5.1.2(i) forbids gating on ATT. - The SKAdNetwork list is Google's full list of
xxxxxxxxxx.skadnetworkidentifiers, generated and checked (--check) before every release. Why: the plugin copies only what it gets, and Google changes the list. - Consent reset, the debug geography and the Ad Inspector exist only in test-only code (the debug adapter and debug options reached through the test-only entry). Why: they change consent behaviour for real users.
- The developer does the AdMob console steps; ask and wait, never invent IDs or publish anything. Why: the console is the developer's account, and an invented ID or a published setting reaches real users.
Workflow
- Install. Read references/setup-ids-and-modes.md:
npm install -E react-native-google-mobile-ads@17.2.0andnpx expo install expo-tracking-transparency expo-network expo-constants expo-application(a child-directed app leaves outexpo-tracking-transparency). - Copy the templates at the paths their first lines name (never rewrite them from memory):
templates/src/services/ads/,templates/src/services/consent/admob-consent-debug-adapter.ts(+ test),templates/src/config/{ads-config,admob-ids,ad-pacing,skadnetwork-ids}.ts(+ tests),templates/src/hooks/,templates/src/ui/ad-banner-slot.tsx(+ test),templates/scripts/ads/,templates/contracts/ads.json(+ schema; fill the banner screens, audience and floors). The synced canonical files (src/services/consent/*,src/services/connectivity/*,src/config/app-variant.ts,src/core/result.ts, the two root mocks) are copied only when the app lacks them; a child-directed app deletesexpo-tracking-consent-adapter.ts(+ tests) and the tracking mock. - Wire
app.config.tsas examples/app-config-ads.ts shows:resolveAdsMode, the two plugin entries,extra: { ...adsExtra(adsMode, ADMOB_IDS) }, the tracking text in every language'slocales(none of the tracking lines for a child-directed app). With EAS the next build applies it; locally rerunnpx expo prebuild --clean. Refresh the native config snapshot (expo-prebuild-and-config-plugins' checker with--write:APPLICATION_IDandDELAY_APP_MEASUREMENT_INITchange) and commit it with the plugin change. Publish every latereas updatewith the channel's environment (--environment <env>,ADS_MODEunset). - Compose the ports in the composition root (examples/create-device-services.ts):
add
ads,consent,connectivity,fullscreenAdtoServices,createDeviceServicesandcreateTestServices(architecture-and-boundaries' port recipe), withreadAdsExtraForBinary(Application.applicationId)and the consent adapter'stracking. - Mount the consent moment. Read references/consent-flow.md; render
AdsRoot(examples/ads-root.tsx) insideRootStackaroundAppStack, with the app's Premium, onboarding and saved consent facts and, optionally, a primer (examples/consent-primer.tsx). Show the "Ad privacy choices" entry only while required: the settings model returnsadPrivacy(examples/use-settings-model.ts), the row is a pure view (examples/ad-privacy-row.tsx). - Place the ads. Read references/ad-policy.md and
references/placements-and-lifecycle.md; follow
examples/use-tasks-model.ts and examples/tasks-screen.tsx:
useBannerSlot('<screen>', facts, useIsFocused())in the model hook andAdBannerSlotin the view;recordBreakwhere a break finishes;showInterstitialIfDuein the press handler that leaves it;useRewardedOfferandearnRewardedPerkfor rewards; thefullscreenAdgate for every fullscreen ad. - Run the tests:
npx jest src/services/ads src/services/consent src/config src/hooks src/ui scripts/ads --ci --maxWorkers=2. - Run the checkers:
node ${CLAUDE_SKILL_DIR}/scripts/check-ads.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-ad-behaviour.mjs .(multi-package: add--app apps/<app>). Fix everyFAILline (file, rule, fix) and rerun until both printRESULT: PASS. - Before a release:
node scripts/ads/refresh-skadnetwork.ts --check; the ads smoke test by hand on anADS_MODE=testbuild, on a named iOS simulator and a named Android emulator (references/console-privacy-troubleshooting.md,templates/maestro/ads-smoke/; declare the folder incontracts/naming.jsonextraE2eFoldersas{ "folder": "ads-smoke", "why": "by-hand ads smoke on ADS_MODE=test builds; CI never runs it" }, so naming-conventions' checkers pass); then hand off the App Privacy and Data safety answers (privacy-and-store-declarations). - Tell the developer, in plain words, which console steps are still open (account, apps and units,
the GDPR message, blocking controls before the first store build; store links and
app-ads.txtafter the first release) and what each needs from them.
Definition of done
- The SDK is exactly 17.2.0 and imported only by the three adapter files;
expo-tracking-transparencyis installed and imported only byexpo-tracking-consent-adapter.ts(general audience) or absent withtracking: null(child-directed); the root mocks exist. - Every template is in place and its Jest suite passes; the consent folder equals the canonical copy.
- The AdMob plugin gets
admobPluginOptions(...);expo.extracomes fromadsExtra(...); no AdMob ID is typed in app code and no app code importsadmob-ids.ts; the app reads it throughreadAdsExtraForBinary(Application.applicationId); the native config snapshot is refreshed. - Consent: launch refresh, the primer (if any) then Google's form, then Apple's prompt while
not-determined (general audience), after onboarding, over a focused ad screen, before the first
ad; nothing initialised or loaded without
canRequestAds; banners only after the ATT answer and a successfulinitialize; declined, restricted or unavailable tracking still initializes;ADS_MODE=offnever calls UMP or ATT; the privacy entry shows only when required. - The purpose text: the plugin entry, every language's catalog, the localised writer; no IDFA explainer in the console; the audience profile matches the adapter and the console rating.
- Every finished break is recorded once; banners only on the declared slots with their testIDs and
a policy-computed
isAllowed, only while focused; interstitials only from press handlers throughshowInterstitialIfDue; rewards only onEARNED_REWARD; media is suspended during fullscreen ads. - Offline, Premium, no consent and
ADS_MODE=offeach show no ad and no message. -
refresh-skadnetwork.ts --checkpasses before a release; the ads smoke flows passed on iOS and Android (or the report says they are owed);contracts/naming.jsondeclaresads-smoke; the developer has done the console steps. - Expected only:
SKIP src/i18n/catalogs [att-plugin] not yet created (i18n-strings-and-catalogs creates it)before the app has catalogs; the placeholder-IDs line (informational, not a problem) until the developer gives the real IDs (never in a release report); a consent-only app's note that the ads rules do not apply yet; andNOT APPLICABLEin an app without the SDK. A multi-package repo runs each app with--app <dir>. -
node ${CLAUDE_SKILL_DIR}/scripts/check-ads.mjs .printsRESULT: PASS -
node ${CLAUDE_SKILL_DIR}/scripts/check-ad-behaviour.mjs .printsRESULT: PASS
Anti-patterns
- Calling
showInterstitialIfDuefrom an effect of the next screen or on app resume (anAppStatelistener), directly or through a helper. The ad covers what the user just opened. Only the press handler that leaves the break calls it (check-adsad-in-effect). - "Upgrading" the child tags to
ageRestrictedTreatment. In react-native-google-mobile-ads 17.2.0 Android drops it silently; keep the deprecated tags (references/consent-flow.md, Audience; check-adscontent-rating). - "Upgrading" the adapter to
useInterstitialAd,useRewardedAdor ad pools. Hooks tie ads to a component; pools preload (and on Android initialise the SDK) before consent. Keep the classic API. - Initialising the SDK at app start "to be ready" (
createAdsPort(...).initialize()). It may preload ads before consent;prepareAdsis the only caller ofinitialize(check-adsport-calls). deps.ads.showInterstitial()straight from a press handler. It skips the pacing policy, the media pause and the history; callshowInterstitialIfDue(check-adsport-calls).- Holding the ad moment while the banner screen is merely mounted. A pushed editor gets the primer,
the form and ATT; pass
useIsFocused()touseBannerSlot. readAdsExtra()in the composition root, oreas updatewith whatever environment the shell has. An update made for another variant changes the ads of installed builds; usereadAdsExtraForBinaryand--environment.- ATT in a child-directed app. Kids Category apps do not implement it; no package, no purpose text,
tracking: null. createAdmobConsentAdapter(...)orcreateAdmobAdsAdapter(...)outside the factories. AnADS_MODE=offbuild then asks Google UMP at launch and the network audit fails.- Typing test unit IDs or the sample publisher ID in code. Use
units: 'test'(the adapter mapsTestIds.*) andGOOGLE_SAMPLE_APP_IDSinads-config.ts. npx expo install react-native-google-mobile-ads. It writes a caret; usenpm install -E.- One AdMob app for both platforms, or iOS units on Android. They are separate AdMob apps; the factory picks this platform's units.
- Hiding an ad failure behind a message, a retry button or a spinner. Ads are silent; log and move on.
- Granting a reward on
CLOSED, or a pressable "Watch an ad" with nothing loaded. UserewardedOffer:'loading'busy,'hidden'gone. - Wrapping
AdBannerSlotin a frame that renders nothing until an ad has loaded. The native banner must be mounted to load; pass the frame asloadedStyle. - Asking ATT at launch, before Google's form, or from a screen. Apple's prompt comes through
ConsentPort.requestTracking()insideprepareAds, over a focused ad screen only. - Hiding a feature, a reward or Premium behind "Allow". 5.1.2(i) forbids gating on the ATT answer.
- Publishing an IDFA explainer in the AdMob console or setting the AdMob plugin's
userTrackingUsageDescription. UMP runs a second ATT flow, and the text gets two writers. - Proving ATT with a flow that starts with
launchApp. Maestro grants every permission; launch withxcrun simctl launch <udid> <bundle id>. - Changing pacing by editing
ad-policy.ts, or lowering a number below its floor "for testing". Changesrc/config/ad-pacing.ts; a testing build already serves test ads. - Putting
AdsConsent.reset()or the Ad Inspector in the consent adapter. They belong to the test-only debug adapter. - Fixing a failing checker by weakening a Jest test or the fixture. The behaviour check runs the real modules; fix the module.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/setup-ids-and-modes.md | Versions, install, the variant x ads-mode matrix, IDs, plugin options, test IDs, the ports per mode, SKAdNetwork, multi-package, re-verify | Workflow steps 1-4 |
| references/consent-flow.md | UMP and ATT rules with sources, Android, the audience profiles, the canonical consent folder, the sequence, the consent moment, the privacy entry, test-only tools | Workflow step 5 |
| references/ad-policy.md | The default pacing, the pure policy, floors, the history, rewarded offers, interpretations, tests | Workflow step 6; before changing any ad rule |
| references/placements-and-lifecycle.md | AdsPort and adapter facts, the banner slot, natural breaks, rewards, pausing media on iOS and Android, offline, React Navigation | Workflow step 6 |
| references/console-privacy-troubleshooting.md | The developer's console steps, App Privacy and Data safety facts, the smoke test, root mocks, error and symptom tables | Workflow steps 9-10; when an ad misbehaves |
| examples/app-config-ads.ts | The ads lines of app.config.ts |
Workflow step 3 |
| examples/create-device-services.ts | The composition root with the ads, consent, connectivity and media gate ports | Workflow step 4 |
| examples/ads-root.tsx | Mounting AdsProvider with the app's facts |
Workflow step 5 |
| examples/consent-primer.tsx | An optional consent primer before Google's form | Workflow step 5 |
| examples/use-tasks-model.ts | A model hook with the banner, a natural break, an interstitial and a rewarded offer | Workflow step 6 |
| examples/tasks-screen.tsx | The banner slot at the bottom of a pure view | Workflow step 6 |
| examples/use-settings-model.ts | The settings model's adPrivacy (reads the consent answer and the port) |
Workflow step 5 |
| examples/ad-privacy-row.tsx | The "Ad privacy choices" entry, a pure view | Workflow step 5 |
templates/src/services/ads/ |
Port, AdMob adapter, fake, factory, read-ads-extra, policy, history, rewarded offer, gate, consent moment flow, moments, fullscreen gate, AdsProvider, its context and hook, with tests | Workflow step 2 |
templates/src/services/consent/ |
ConsentPort, the AdMob consent adapter (+ iOS and Android tests), the expo-tracking-transparency ATT adapter (+ iOS and Android tests; general audience only), the factory, the fake (+ tests): synced canonical files this skill owns; the test-only debug adapter (+ test) | Workflow step 2 |
templates/src/services/connectivity/ |
The ConnectivityPort, expo-network adapter, reachability hub, fake (synced; networking-and-server-state owns them) | Workflow step 2 |
templates/src/config/ |
ads-config.ts, admob-ids.ts, ad-pacing.ts (+ tests), skadnetwork-ids.ts (Google's 50 IDs, 2026-10-10); app-variant.ts (+ test, synced) |
Workflow steps 2-3 |
templates/src/hooks/ |
useAdContext, useBannerSlot, useRewardedOffer (+ tests) |
Workflow step 6 |
templates/src/ui/ |
ad-banner-slot.tsx (collapsed until loaded) and its test |
Workflow step 6 |
templates/src/core/ |
result.ts (Result, ok, err, attemptAsync) and its test (synced; error-handling-typed-results owns them) |
Workflow step 2 |
templates/scripts/ads/ |
refresh-skadnetwork.ts and the tested skadnetwork-list.ts |
Workflow step 9 |
templates/contracts/ |
ads.json (the app's ads contract) and ads.schema.json (every key documented) |
Workflow step 2 |
templates/maestro/ads-smoke/ |
Six Maestro flows of the by-hand smoke test (they land in .maestro/ads-smoke/) |
Workflow step 9 |
templates/__mocks__/ |
The SDK and tracking root mocks (synced; do not edit here) | Workflow step 2 |
assets/admob-facts.json |
Version, adapter allowlists, banned names, ID formats, SKAdNetwork facts, default floors, required files | Read by the checkers; update on an SDK upgrade |
scripts/check-ads.mjs |
Static checks: files, pin, imports, SDK API, factories, test-only tools, IDs, ATT order/plugin/text/wiring per audience, plugin and extra wiring, the binary guard, SKAdNetwork, audience, pacing, placements, effects, port calls, breaks, the consent moment and focus, the smoke folder, free allowances | Workflow step 8 |
scripts/check-ad-behaviour.mjs |
Runs the app's modules against the decision tables and the adapters and factories against scripted stand-ins | Workflow step 8 |
scripts/lib/ads-facts.mjs |
The facts, the layout paths, the contract reader and the scanned sources | Only to change a checker |
scripts/lib/consent-rules.mjs |
check-ads' consent, ATT and SDK-boundary rules | Only to change a checker |
scripts/lib/ads-rules.mjs |
check-ads' ads-module, wiring and placement rules | Only to change a checker |
scripts/lib/consent-scenarios.mjs |
check-ad-behaviour's consent adapter, tracking and factory scenarios | Only to change a checker |
scripts/lib/ads-scenarios.mjs |
check-ad-behaviour's policy, offer, gate, flow, moments, config, adapter, factory and extra scenarios | Only to change a checker |
scripts/lib/load-ts.mjs |
Loads the app's TypeScript (type stripping, tsconfig aliases) with the stand-ins | Only to change a checker |
scripts/lib/repo-scan.mjs |
File lists (check-lib's repo walk) and import and call helpers | Only to change a checker |
scripts/lib/stubs/ |
Scripted stand-ins of react-native-google-mobile-ads 17.2.0, expo-tracking-transparency, react-native, react and expo-constants (update on an SDK upgrade) |
Only to change a checker |
scripts/lib/app-modules.mjs |
Imports the app's pure TypeScript (synced; do not edit here) | Never by hand |
scripts/check-lib.mjs |
Shared script helper (synced; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers: the shipped templates and examples pass, every planted bug fails for its rule | After changing a checker, a template or a fixture |
assets/shared.json |
The shared files copied into this skill | When adding a shared file |
tests/ |
The base app (with silent planted findings under .agents/skills/, skills/ and a vendored folder) and the overlay cases per checker |
When adding a rule |
Related skills
in-app-purchases- the Premium entitlement that removes every ad.privacy-and-store-declarations- privacy manifests, App Privacy and Data safety answers, the network audit.architecture-and-boundaries- ports, adapters, the composition root and SDK classification.networking-and-server-state- the ConnectivityPort the ads read.debug-menu-and-test-hooks- the test-only gate, the debug link and simulated offline.settings-and-preferences- the look of the "Ad privacy choices" row.i18n-strings-and-catalogs- the tracking purpose text in every language.persistence-and-migrations- where the ad history and the consent answer are saved.app-variants-and-environment- the variantsresolveAdsModebuilds on.audio-and-haptics-feedback- audio that follows the fullscreen gate.navigation-and-routing,app-startup-and-bootstrap- whereAdsProvidersits in the root tree.e2e-maestro,ios-simulator-build- running the smoke flows on named devices.ios-release-testflight,android-release-play- the release gates that refuse the placeholder or sample AdMob app ID in a store build.expo-prebuild-and-config-plugins- the native config snapshot the plugin change refreshes.ota-updates- publishing EAS Updates with the channel's environment.naming-conventions- theextraE2eFoldersentry of the smoke flows.dependency-management- pinning and upgrading the SDK.analytics-and-consent- analytics has its own consent; it shares only the ATT half (TrackingConsent).