Deep Links skill for Claude Code
One https URL that opens the right screen everywhere. Claude sets up Universal Links and App Links so verified https URLs open the exact screen, with association files served without redirects, exclude rules ordered first, and the Play app signing fingerprint rather than the upload key. One URL parser serves links and push taps, signed-out users return to their target after login, and a bundled script validates both files and Apple's CDN copy.
- name
deep-links - size220 lines · ≈2,450 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Sets up and debugs iOS Universal Links and Android App Links end to end — apple-app-site-association and assetlinks.json hosting, Associated Domains and autoVerify intent filters, signing-certificate fingerprints with Play App Signing, a single URL-to-route parser, auth-gated routing, web fallbacks, and deferred deep links after install now that Firebase Dynamic Links is gone. Includes a validator script for both association files. Use when links open the browser instead of the app, setting up a new link domain, routing push or email links into screens, or replacing Firebase Dynamic Links. Try asking
- Our links open Chrome instead of the app on Android 13 — figure out why
- Set up universal links for example.com and route /orders/:id to the order screen
- We used Firebase Dynamic Links for invites; what should replace it?
Use it when
- Links open the browser instead of the installed app
- Adding a new link domain or path to an app
- Routing email, SMS or push links into specific screens
- Replacing Firebase Dynamic Links or adding deferred deep links
Skip it when
- The problem is notification delivery or permissions — use mobile-push-notifications
- You need in-app navigation structure for a framework — use react-native-expo, swiftui-views or flutter-widgets
How to check the result
The association-file script passes for each domain, adb get-app-links reports every domain verified on a Play build, and the cold-start, signed-out and not-installed cases behave on real devices.
The common mistake it prevents
Putting the upload key fingerprint in assetlinks.json when Play App Signing re-signs the app, so verification fails only for store installs.
Deep linking works when the same https:// URL opens the right screen in the installed
app on both platforms, renders a useful web page when the app is not installed, survives
the sign-in wall, and is verified by the OS — not by a custom URL scheme any other app
could claim.
Link types
| Type | Opens | Use |
|---|---|---|
| Universal Links (iOS) / App Links (Android) | App if installed and verified, else website | Default for every shareable, emailed, or pushed link |
Custom scheme (acme://orders/42) | App only; unverified, any app can register the same scheme | Internal callbacks, OAuth redirects in dev; never the primary public link |
| Deferred deep link | Store, then the intended screen on first launch | Invites, referrals, campaign installs |
Workflow
- [ ] 1. Choose link domain(s) and the URL → screen map
- [ ] 2. Host association files (AASA, assetlinks.json) correctly
- [ ] 3. iOS: Associated Domains entitlement; Android: autoVerify intent filters
- [ ] 4. One URL parser shared by links, push taps, and in-app navigation
- [ ] 5. Auth gating with a pending route; unknown URLs fall back safely
- [ ] 6. Web fallback page for every linked path (+ smart app banner)
- [ ] 7. Deferred deep links if installs must keep context
- [ ] 8. Validate files with the script; test on devices (matrix below)
1. Hosting the association files
Both files must be served from the exact domain in the link, over HTTPS with a valid
certificate, with HTTP 200, Content-Type: application/json, and no redirects (the OS
does not follow them). Subdomains need their own files (www. and apex are different).
https://example.com/.well-known/apple-app-site-association (no .json extension):
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.acme.app"],
"components": [
{ "/": "/account/delete", "exclude": true, "comment": "always web" },
{ "/": "/orders/*" },
{ "/": "/invite", "?": { "code": "?*" } },
{ "/": "/p/*" }
]
}
]
},
"webcredentials": { "apps": ["ABCDE12345.com.acme.app"] }
}
- Rules evaluate top to bottom; the first match wins, so put
excluderules first. appIDsentries areTEAMID.bundleID(Team ID from the developer account, not the App ID prefix of old apps unless they match).- Apple devices fetch the file through Apple’s CDN, not your server directly. Changes
can take hours to propagate; check what Apple has cached at
https://app-site-association.cdn-apple.com/a/v1/example.com.
https://example.com/.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.acme.app",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}]
- With Play App Signing, Google re-signs your app: the required fingerprint is the app signing key from Play Console (Setup → App signing), not your upload key. Add the upload and debug fingerprints too if you test non-Play builds.
- Get local fingerprints with
./gradlew signingReportorkeytool -list -v -keystore release.keystore -alias upload.
Validate before testing on devices:
python3 scripts/check_association_files.py --domain example.com \
--app-id ABCDE12345.com.acme.app --package com.acme.app \
--fingerprint 14:6D:...:44:E5 --path /orders/42 --path /account/delete --apple-cdn
python3 scripts/check_association_files.py --aasa-file aasa.json --assetlinks-file assetlinks.json --app-id ABCDE12345.com.acme.app
It refuses redirects like the OS does, checks content type, size, app IDs, fingerprint
format and presence, reports which test paths open the app, and compares Apple’s CDN copy
with your origin. Exit 1 means invalid files; exit 2 means a network failure (on
python.org macOS builds, run “Install Certificates.command” if TLS verification fails).
Google’s Digital Asset Links API offers a second opinion:
https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://example.com&relation=delegate_permission/common.handle_all_urls.
2. App configuration
iOS — add the Associated Domains capability with applinks:example.com (and
webcredentials:example.com for password autofill). During development,
applinks:example.com?mode=developer fetches directly from your server on devices with
Developer Mode enabled, bypassing the CDN.
// SwiftUI
WindowGroup { RootView() }
.onOpenURL { url in router.handle(url) } // universal links and custom schemes
// UIKit scene delegate: scene(_:continue:) with NSUserActivityTypeBrowsingWeb → userActivity.webpageURL
Android — one intent filter per host with verification:
<activity android:name=".MainActivity" android:launchMode="singleTask" android:exported="true">
<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="/orders" />
<data android:scheme="https" android:host="example.com" android:pathPrefix="/invite" />
</intent-filter>
</activity>
Handle the URL in onCreate (cold start) and onNewIntent (app running). On Android
12+, unverified https links open in the browser rather than a chooser, so verification
failures look like “links just open Chrome”.
Cross-platform: Expo Router maps URLs to routes automatically once scheme and
associated domains/intent filters are configured; React Navigation needs a linking
config; Flutter uses go_router routes with platform deep-linking enabled (check the
FlutterDeepLinkingEnabled / flutter_deeplinking_enabled settings for your Flutter
version).
3. Routing
- One parser converts a URL into a typed route; links, push payloads, widgets, and Spotlight/shortcuts all go through it.
- Validate parameters (IDs are well-formed, the record belongs to the user) after loading; show a not-found screen for unknown or forbidden content instead of crashing.
- Links never perform side effects by themselves (no “confirm transfer” or “delete” on open). Show a confirmation screen; links can be forged.
- Auth gating: if the route requires sign-in, save it as a pending route, show sign-in, then navigate. Clear the pending route after use.
- Build the back stack so Back from a deep-linked detail goes to a sensible parent (orders list), not out of the app.
4. Web fallback
Every linkable path must render real content on the web (or a landing page with store
links), because link previews, desktop users, and users without the app hit it. Add
<meta name="apple-itunes-app" content="app-id=123456789, app-argument=https://example.com/orders/42">
for the iOS Smart App Banner.
5. Deferred deep links
Firebase Dynamic Links was shut down in August 2025; existing page.link URLs no longer
resolve. Options:
| Approach | Notes |
|---|---|
| Attribution/linking vendor (Branch, AppsFlyer OneLink, Adjust) | Handles install attribution and first-open routing on both platforms; review privacy disclosures |
| Android Play Install Referrer API | Deterministic: pass referrer= on the Play Store URL, read it on first launch |
| Account-based claim | Store the intent server-side keyed to email/phone; after sign-up, fetch and route. Deterministic and private |
| iOS clipboard handoff | Triggers a paste-permission prompt; avoid as the default |
Do not use device fingerprinting for attribution on iOS without considering App Tracking Transparency rules.
Gotchas
- iOS ignores Universal Links when the user taps a link to the same domain while already on that domain in Safari, or types the URL into the address bar. Test from Notes or Messages.
- A user who chooses “Open in Safari” from the link’s long-press menu disables Universal Links for that domain on that device until they choose “Open in app”.
- Many in-app browsers (social apps, some email clients) open links in their own web view and never hand off to the app; a web fallback with an explicit “Open in app” button helps.
- Redirect-based click tracking (email providers rewriting links to their domain) breaks Universal Links unless the tracking domain itself has an AASA for your app, or you configure the provider’s universal-link support.
- After changing assetlinks.json, re-run verification on test devices; Android caches results.
Test matrix
# iOS Simulator
xcrun simctl openurl booted "https://example.com/orders/42"
# Android
adb shell pm verify-app-links --re-verify com.acme.app
adb shell pm get-app-links com.acme.app # each domain should show "verified"
adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/orders/42" com.acme.app
| Case | Expected |
|---|---|
| App installed, cold start | Opens the exact screen |
| App running in background | Navigates without duplicating screens |
| Signed out | Sign-in, then the target screen |
Excluded path (/account/delete) | Opens the website |
| App not installed | Website renders content or store landing |
| Unknown/forbidden ID | Not-found screen in app |
Verification
- The script passes for every link domain against the production app ID, package, and Play signing fingerprint.
pm get-app-linksshows all domains verified on a release build from Play (internal testing track).- Every row of the test matrix passes on a physical iPhone and Android phone.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/check_association_files.py
#!/usr/bin/env python3
"""Validate apple-app-site-association and assetlinks.json (stdlib only).
Live check (fetches https://DOMAIN/.well-known/... and refuses redirects, like the OS does):
python3 check_association_files.py --domain example.com \
[--app-id ABCDE12345.com.acme.app] [--package com.acme.app] \
[--fingerprint AA:BB:...] [--path /orders/42 --path /account] [--apple-cdn]
Offline check of local files:
python3 check_association_files.py --aasa-file aasa.json --assetlinks-file assetlinks.json [same options]
--apple-cdn also fetches Apple's cached copy (what devices actually receive).
Exit 1 on validation errors, 2 on network/usage errors.
"""
import argparse
import fnmatch
import json
import re
import sys
import urllib.error
import urllib.request
APP_ID_RE = re.compile(r"^[A-Z0-9]{10}\.[A-Za-z0-9.\-*]+$")
FP_RE = re.compile(r"^([0-9A-F]{2}:){31}[0-9A-F]{2}$")
HANDLE_ALL = "delegate_permission/common.handle_all_urls"
errors, warnings, infos = [], [], []
network_failures = []
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} (not allowed)", headers, fp)
def fetch(url):
opener = urllib.request.build_opener(NoRedirect)
req = urllib.request.Request(url, headers={"User-Agent": "association-check/1.0"})
try:
with opener.open(req, timeout=15) as r:
body = r.read()
return r.status, r.headers.get("Content-Type", ""), body
except urllib.error.HTTPError as e:
errors.append(f"{url}: HTTP {e.code} {e.reason}")
except (urllib.error.URLError, TimeoutError) as e:
network_failures.append(f"{url}: {e}")
return None, None, None
def parse_json(label, body):
try:
return json.loads(body)
except (ValueError, UnicodeDecodeError) as e:
errors.append(f"{label}: invalid JSON ({e})")
return None
def component_matches(comp, path, query, fragment):
def m(pattern, value):
return fnmatch.fnmatchcase(value, pattern) if comp.get("caseSensitive", True) \
else fnmatch.fnmatchcase(value.lower(), pattern.lower())
if "/" in comp and not m(comp["/"], path):
return False
q = comp.get("?")
if isinstance(q, str) and not m(q, query):
return False
if isinstance(q, dict):
params = dict(p.split("=", 1) if "=" in p else (p, "") for p in query.split("&") if p)
for k, pat in q.items():
if k not in params or not m(pat, params[k]):
return False
if "#" in comp and not m(comp["#"], fragment):
return False
return True
def check_aasa(data, app_id, paths):
if not isinstance(data, dict):
errors.append("AASA: top level must be an object"); return
applinks = data.get("applinks")
if not isinstance(applinks, dict):
errors.append("AASA: missing 'applinks' object"); return
details = applinks.get("details")
if not isinstance(details, list) or not details:
errors.append("AASA: applinks.details must be a non-empty array"); return
found_app = False
for i, d in enumerate(details):
ids = d.get("appIDs") or ([d["appID"]] if "appID" in d else [])
if "appID" in d:
warnings.append(f"AASA details[{i}]: legacy 'appID'/'paths' format; prefer 'appIDs' + 'components' (iOS 13+)")
if not ids:
errors.append(f"AASA details[{i}]: no appIDs")
for a in ids:
if not APP_ID_RE.match(a):
errors.append(f"AASA details[{i}]: '{a}' is not TEAMID.bundle.id (10-char uppercase Team ID)")
comps = d.get("components")
if comps is None and "paths" not in d:
errors.append(f"AASA details[{i}]: needs 'components' (or legacy 'paths')")
for j, c in enumerate(comps or []):
if not isinstance(c, dict):
errors.append(f"AASA details[{i}].components[{j}] must be an object"); continue
for k in c:
if k not in ("/", "?", "#", "exclude", "comment", "caseSensitive", "percentEncoded"):
warnings.append(f"AASA details[{i}].components[{j}]: unknown key '{k}'")
if "/" in c and not str(c["/"]).startswith(("/", "*")):
errors.append(f"AASA details[{i}].components[{j}]: path pattern must start with '/'")
if app_id and app_id in ids:
found_app = True
for p in paths:
path, _, rest = p.partition("?")
query, _, frag = rest.partition("#")
if "#" in path:
path, _, frag = path.partition("#")
verdict = "no rule matched -> opens in browser"
for c in comps or []:
if component_matches(c, path, query, frag):
verdict = "EXCLUDED -> opens in browser" if c.get("exclude") else "opens app"
break
infos.append(f"AASA path {p}: {verdict}")
if app_id and not found_app:
errors.append(f"AASA: app ID {app_id} not listed in any applinks.details entry")
wc = data.get("webcredentials", {}).get("apps") if isinstance(data.get("webcredentials"), dict) else None
if wc is not None and app_id and app_id not in wc:
warnings.append(f"AASA: webcredentials present but {app_id} not listed (password autofill association)")
def check_assetlinks(data, package, fingerprint):
if not isinstance(data, list):
errors.append("assetlinks: top level must be an array of statements"); return
matched_pkg = False
for i, s in enumerate(data):
rel, tgt = s.get("relation", []), s.get("target", {})
if tgt.get("namespace") != "android_app":
continue
if HANDLE_ALL not in rel:
warnings.append(f"assetlinks[{i}]: relation lacks {HANDLE_ALL} (needed for App Links)")
fps = tgt.get("sha256_cert_fingerprints", [])
if not fps:
errors.append(f"assetlinks[{i}]: no sha256_cert_fingerprints")
for fp in fps:
if not FP_RE.match(fp):
errors.append(f"assetlinks[{i}]: fingerprint '{fp}' must be 32 uppercase hex bytes separated by colons")
if package and tgt.get("package_name") == package:
matched_pkg = True
if fingerprint and fingerprint.upper() not in [f.upper() for f in fps]:
errors.append(f"assetlinks: {package} found but fingerprint {fingerprint} missing "
"(with Play App Signing use the app signing key from Play Console)")
if package and not matched_pkg:
errors.append(f"assetlinks: no android_app statement for package {package}")
def main():
ap = argparse.ArgumentParser(description="Validate AASA and assetlinks.json")
ap.add_argument("--domain")
ap.add_argument("--aasa-file")
ap.add_argument("--assetlinks-file")
ap.add_argument("--app-id", help="TEAMID.bundle.id")
ap.add_argument("--package", help="Android applicationId")
ap.add_argument("--fingerprint", help="SHA-256 signing cert fingerprint AA:BB:...")
ap.add_argument("--path", action="append", default=[], help="URL path to test against AASA rules (repeatable)")
ap.add_argument("--apple-cdn", action="store_true", help="also fetch Apple's CDN copy")
args = ap.parse_args()
if not (args.domain or args.aasa_file or args.assetlinks_file):
ap.print_usage(sys.stderr); return 2
if args.aasa_file:
with open(args.aasa_file, "rb") as fh:
d = parse_json("AASA", fh.read())
if d is not None:
check_aasa(d, args.app_id, args.path)
elif args.domain:
url = f"https://{args.domain}/.well-known/apple-app-site-association"
status, ctype, body = fetch(url)
if body is not None:
if "json" not in (ctype or ""):
warnings.append(f"AASA served as '{ctype}'; serve application/json")
if len(body) > 128 * 1024:
errors.append(f"AASA is {len(body)} bytes; Apple's limit is 128 KB")
d = parse_json("AASA", body)
if d is not None:
check_aasa(d, args.app_id, args.path)
if args.apple_cdn:
cdn = f"https://app-site-association.cdn-apple.com/a/v1/{args.domain}"
_, _, cbody = fetch(cdn)
if cbody is not None and body is not None:
same = parse_json("AASA (Apple CDN)", cbody) == parse_json("AASA", body)
(infos if same else warnings).append(
"Apple CDN copy matches origin" if same else "Apple CDN copy differs from origin (CDN cache not refreshed yet)")
if args.assetlinks_file:
with open(args.assetlinks_file, "rb") as fh:
d = parse_json("assetlinks", fh.read())
if d is not None:
check_assetlinks(d, args.package, args.fingerprint)
elif args.domain and (args.package or args.fingerprint or not args.app_id):
url = f"https://{args.domain}/.well-known/assetlinks.json"
status, ctype, body = fetch(url)
if body is not None:
if "json" not in (ctype or ""):
warnings.append(f"assetlinks.json served as '{ctype}'; serve application/json")
d = parse_json("assetlinks", body)
if d is not None:
check_assetlinks(d, args.package, args.fingerprint)
for i in infos:
print(f"INFO {i}")
for w in warnings:
print(f"WARN {w}")
for e in errors:
print(f"ERROR {e}")
for n in network_failures:
print(f"NETWORK {n}")
print(f"{len(errors)} error(s), {len(warnings)} warning(s), {len(network_failures)} network failure(s)")
if errors:
return 1
return 2 if network_failures else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.