Mobile Deep Links skill for Codex
Links that open the app, not the browser. Codex sets up universal links and Android App Links from the hosting side inward: it validates the apple-app-site-association and assetlinks.json files with a bundled script, adds the Play App Signing fingerprint people forget, and funnels every URL entry point through one typed router that never performs actions on its own. It also plans deferred links now that Dynamic Links is gone.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- mobile-deep-links Installs to ~/.agents/skills/mobile-deep-links/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install mobile-deep-links The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project mobile-deep-links Installs to .agents/skills/mobile-deep-links/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/mobile-deep-links.zipunzip -o mobile-deep-links.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) mobile-deep-links PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $mobile-deep-links, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sets up and debugs iOS universal links, Android App Links, custom URL schemes and deferred deep links - apple-app-site-association and assetlinks.json hosting, associated domains entitlements, autoVerify intent filters, Play App Signing fingerprints, a single in-app URL router, and post-install attribution after Firebase Dynamic Links shut down. Includes a validator script for both association files. Use when links open the browser instead of the app, when adding a new linkable path, or when replacing Dynamic Links.
Use it when
- Links open the browser instead of the installed app
- Adding new shareable paths or a new link domain
- Replacing Firebase Dynamic Links or adding post-install routing
- Notification, widget and URL entry points each parse links differently
Use something else when
- Routing inside an Expo app with file-based routes: use expo-router
- SwiftUI NavigationStack state restoration without external links: use swiftui-navigation
How to check the result
check_link_files.py exits 0 for both live association files, adb pm get-app-links shows every host verified, and links tapped from Notes and Gmail open the target screen cold and warm.
The mistake it prevents
Listing only the upload key fingerprint in assetlinks.json, so builds re-signed by Play App Signing never verify.
Done well means: every shareable https URL opens the right screen in the installed app on both platforms, falls back to a working web page when the app is absent, survives install when that matters, and is parsed by one router that treats the URL as untrusted input.
Before changing anything
- Read
AGENTS.md, then find the current link handling: SwiftUI.onOpenURL, scene delegatescene(_:continue:), Android intent filters inAndroidManifest.xml, Expo or React Navigation linking config, and any third-party SDK (Branch, AppsFlyer, Adjust). - List every domain and path the product shares (emails, SMS, QR codes, marketing pages).
- Fetch the live association files (needs network) and run the validator below.
- Collect identities: Apple Team ID and bundle ID per target; Android application ID and the SHA-256 of the Play App Signing key from Play Console > Test and release > App integrity, plus upload and debug keys if those builds must verify.
Choose the mechanism
| Need | Use | Avoid |
|---|---|---|
| Links shared on the web, email, SMS | Universal links + App Links on https | Custom schemes (no verification, any app can claim them) |
| OAuth redirect back into the app | ASWebAuthenticationSession callback scheme / Android App Link or claimed https redirect | Embedded web views |
| App-to-app handoff you control | Custom scheme as a secondary path | Relying on it from untrusted sources |
| Open the right screen after a fresh install | Deferred deep link (see below) | Device fingerprinting |
iOS universal links
Serve https://<domain>/.well-known/apple-app-site-association with HTTP 200, no
redirects, Content-Type: application/json, no file extension, under 128 KB.
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.shop"],
"components": [
{ "/": "/account/delete*", "exclude": true, "comment": "web only" },
{ "/": "/p/*" },
{ "/": "/orders/*", "?": { "ref": "?*" } }
]
}
]
}
}
- Components are evaluated in order; put
excluderules before broader includes. - Add
applinks:example.com(andapplinks:www.example.comif used) to thecom.apple.developer.associated-domainsentitlement of the app target. - Apple fetches the file through its CDN, not from the device, so changes can take hours
to appear and the CDN must be able to reach the host (no IP allowlists, no auth). For
staging, use
applinks:staging.example.com?mode=developeron devices with Associated Domains Development enabled in Developer settings. - Handle the link in SwiftUI with
.onOpenURL { router.open($0) }, or in UIKit viaNSUserActivityTypeBrowsingWebin bothscene(_:willConnectTo:options:)(cold start) andscene(_:continue:)(warm).
Android App Links
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" android:pathPrefix="/p/" />
</intent-filter>
Serve https://example.com/.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.shop",
"sha256_cert_fingerprints": ["<Play App Signing key SHA-256>", "<upload or debug key SHA-256>"]
}
}]
- Every host in every
autoVerifyfilter must verify; one failing host historically disabled verification for the whole app, so keep link hosts in a dedicated filter. - Since Android 12, unverified https links open in the browser, not a chooser. A missing Play signing fingerprint is the top cause of “works from Android Studio, not from Play”.
- Read the URI in
onCreateandonNewIntent(or Compose NavigationdeepLinks), and setlaunchModeso a second link does not stack duplicate activities.
One router
Parse every entry point (universal link, App Link, scheme, notification payload, widget URL, shortcut) into a typed route, then navigate. Nothing else interprets URLs.
enum Route: Equatable { case product(id: String), order(id: String), home }
func route(for url: URL) -> Route {
let parts = url.pathComponents.filter { $0 != "/" }
switch (parts.first, parts.dropFirst().first) {
case ("p", let id?) where id.range(of: #"^[A-Za-z0-9_-]{1,64}$"#, options: .regularExpression) != nil:
return .product(id: id)
case ("orders", let id?): return .order(id: id)
default: return .home
}
}
- Validate IDs and parameters; unknown paths go home or to a web fallback, never crash.
- A link never performs a state-changing action by itself (payments, deletes, follows). Navigate to a screen and let the user confirm.
- If the route needs login, store the pending route, authenticate, then resume it.
- Keep the route table shared with the web so the same URL renders on both.
Deferred deep links
Firebase Dynamic Links was shut down in August 2025; any *.page.link URL is dead.
| Option | When |
|---|---|
| Attribution provider (Branch, AppsFlyer OneLink, Adjust) | Marketing needs campaign attribution across both stores |
| Android Play Install Referrer API | Pass a route in the Play Store referrer parameter; read once on first launch; deterministic |
| iOS first-launch handoff | No deterministic API. Ask the user to tap “Continue” on a web page that sets a short-lived code, or use App Clips for instant flows |
Do not fingerprint devices (IP plus user agent) yourself; it violates Apple’s tracking rules without ATT consent and is unreliable. Clipboard reads trigger a visible paste prompt on iOS.
Validator script
python3 scripts/check_link_files.py aasa https://example.com/.well-known/apple-app-site-association --app-id ABCDE12345.com.example.shop
python3 scripts/check_link_files.py assetlinks https://example.com/.well-known/assetlinks.json --package com.example.shop --sha256 14:6D:...:E5
python3 scripts/check_link_files.py aasa web/public/.well-known/apple-app-site-association # local file, offline
It refuses redirects, checks status and Content-Type, validates structure, app ID and fingerprint formats, and warns about exclude ordering and single-fingerprint asset links. Exit code 1 means a blocking error.
Device checks
# Android: state of verification for each host, then force a re-check
adb shell pm get-app-links com.example.shop
adb shell pm verify-app-links --re-verify com.example.shop
adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/p/42" com.example.shop
# iOS simulator: open a URL as if tapped
xcrun simctl openurl booted "https://example.com/p/42"
On iOS devices, test from Notes or Messages: typing the URL into Safari’s address bar, or tapping a same-domain link inside Safari, intentionally stays in the browser.
Gotchas
- Long-press “Open in Safari” on a universal link makes iOS remember that choice for the domain; the user restores it from the banner at the top of the page.
- In-app browsers (social apps) often block universal links; give the web fallback an “Open in app” button pointing at the same https URL.
- Sub-domains need their own entries and their own hosted files.
- Changing a CDN, WAF or redirect rule on the marketing site can silently break links; add the validator to the web deploy pipeline.
Output
**Domains:** <host -> AASA ok/err, assetlinks ok/err>
**Routes:** <URL pattern -> screen, auth needed, web fallback>
**Deferred:** <provider or referrer approach>
**Tested:** <platform, entry point (Notes, Gmail, QR), cold/warm> -> opened screen What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Mobile Deep Links"
short_description: "Links that open the app, not the browser"
default_prompt: "$mobile-deep-links Our universal links open Safari instead of the app, figure out why" scripts/check_link_files.py
#!/usr/bin/env python3
"""Validate iOS apple-app-site-association and Android assetlinks.json files.
Usage:
python3 check_link_files.py aasa <path-or-https-url> [--app-id TEAMID.bundle.id]
python3 check_link_files.py assetlinks <path-or-https-url> [--package com.example] [--sha256 AA:BB:...]
Given an https URL, the file is fetched without following redirects (both platforms
reject redirected association files) and the status and Content-Type are checked.
Fetching needs network access. Exits 0 when no errors are found, 1 otherwise.
"""
import argparse
import json
import re
import sys
import urllib.error
import urllib.request
errors: list[str] = []
warnings: list[str] = []
APP_ID = re.compile(r"^[A-Z0-9]{10}\.[A-Za-z0-9.\-]+$")
FINGERPRINT = re.compile(r"^([0-9A-F]{2}:){31}[0-9A-F]{2}$")
PACKAGE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$")
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(req.full_url, code, f"redirect to {newurl}", headers, fp)
def load(source: str):
if not source.startswith(("http://", "https://")):
with open(source, "rb") as fh:
raw = fh.read()
else:
if source.startswith("http://"):
errors.append("association files must be served over https")
opener = urllib.request.build_opener(NoRedirect)
req = urllib.request.Request(source, headers={"User-Agent": "link-file-check/1.0"})
try:
with opener.open(req, timeout=15) as resp:
ctype = resp.headers.get("Content-Type", "")
if "application/json" not in ctype:
errors.append(f"Content-Type is '{ctype}', expected application/json")
raw = resp.read()
except urllib.error.HTTPError as exc:
errors.append(f"HTTP {exc.code} for {source}: {exc.reason}")
return None
except urllib.error.URLError as exc:
errors.append(f"could not fetch {source}: {exc.reason}")
return None
if len(raw) > 128 * 1024:
errors.append(f"file is {len(raw)} bytes; Apple rejects AASA files over 128 KB")
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
errors.append(f"not valid UTF-8 JSON: {exc}")
return None
def check_aasa(data, want_app_id):
applinks = data.get("applinks") if isinstance(data, dict) else None
if not isinstance(applinks, dict):
errors.append("missing top-level 'applinks' object")
return
details = applinks.get("details")
if not isinstance(details, list) or not details:
errors.append("'applinks.details' must be a non-empty array")
return
if "apps" in applinks:
warnings.append("'applinks.apps' is a legacy key and can be removed")
seen_ids = set()
for i, entry in enumerate(details):
ids = entry.get("appIDs") or ([entry["appID"]] if "appID" in entry else [])
if "appID" in entry:
warnings.append(f"details[{i}] uses legacy 'appID'; prefer 'appIDs'")
if not ids:
errors.append(f"details[{i}] has no appIDs")
for app_id in ids:
if not APP_ID.match(app_id):
errors.append(f"details[{i}] app ID '{app_id}' is not TEAMID.bundle.id")
seen_ids.add(app_id)
comps = entry.get("components")
paths = entry.get("paths")
if comps is None and paths is None:
errors.append(f"details[{i}] has neither 'components' nor 'paths'")
if paths is not None:
warnings.append(f"details[{i}] uses legacy 'paths'; 'components' is the current format")
for j, comp in enumerate(comps or []):
if not isinstance(comp, dict):
errors.append(f"details[{i}].components[{j}] must be an object")
continue
path = comp.get("/")
if path is not None and not str(path).startswith(("/", "*", "?")):
errors.append(f"details[{i}].components[{j}] path '{path}' should start with /")
if comp.get("exclude") and j == len(comps) - 1 and len(comps) > 1:
warnings.append(
f"details[{i}] ends with an exclude rule; rules match in order, so put excludes before broad includes"
)
if want_app_id and want_app_id not in seen_ids:
errors.append(f"app ID {want_app_id} is not listed (found: {', '.join(sorted(seen_ids)) or 'none'})")
def check_assetlinks(data, want_package, want_sha):
if not isinstance(data, list) or not data:
errors.append("assetlinks.json must be a non-empty JSON array")
return
found = {}
for i, stmt in enumerate(data):
rel = stmt.get("relation", [])
if "delegate_permission/common.handle_all_urls" not in rel:
warnings.append(f"[{i}] lacks relation delegate_permission/common.handle_all_urls (no App Links)")
target = stmt.get("target", {})
if target.get("namespace") != "android_app":
continue
pkg = target.get("package_name", "")
if not PACKAGE.match(pkg):
errors.append(f"[{i}] package_name '{pkg}' is not a valid application ID")
prints = target.get("sha256_cert_fingerprints", [])
if not prints:
errors.append(f"[{i}] has no sha256_cert_fingerprints")
for fp in prints:
if not FINGERPRINT.match(fp):
errors.append(f"[{i}] fingerprint '{fp}' must be 32 uppercase hex bytes separated by colons")
found.setdefault(pkg, set()).update(prints)
if want_package:
if want_package not in found:
errors.append(f"package {want_package} not listed (found: {', '.join(sorted(found)) or 'none'})")
elif want_sha and want_sha.upper() not in found[want_package]:
errors.append(f"fingerprint {want_sha} not listed for {want_package}")
for pkg, prints in found.items():
if len(prints) == 1:
warnings.append(
f"{pkg} lists one fingerprint; with Play App Signing include the Play signing key (and debug/upload keys if those builds must verify)"
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("kind", choices=["aasa", "assetlinks"])
ap.add_argument("source", help="local file path or https URL")
ap.add_argument("--app-id", help="expected TEAMID.bundle.id (aasa)")
ap.add_argument("--package", help="expected Android application ID (assetlinks)")
ap.add_argument("--sha256", help="expected signing certificate fingerprint (assetlinks)")
args = ap.parse_args()
data = load(args.source)
if data is not None:
if args.kind == "aasa":
check_aasa(data, args.app_id)
else:
check_assetlinks(data, args.package, args.sha256)
for w in warnings:
print(f"WARN {w}")
for e in errors:
print(f"ERROR {e}")
print(f"{args.kind}: {len(errors)} error(s), {len(warnings)} warning(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.