Mobile No. 332

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.

  • namedeep-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

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.

~/.claude/skills/deep-links/SKILL.md

--- name: deep-links description: Sets up and debugs iOS Universal Links and Android App Links end to end — apple-app-site-association and assetlinks.json hosting,… license: MIT ---

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.

TypeOpensUse
Universal Links (iOS) / App Links (Android)App if installed and verified, else websiteDefault for every shareable, emailed, or pushed link
Custom scheme (acme://orders/42)App only; unverified, any app can register the same schemeInternal callbacks, OAuth redirects in dev; never the primary public link
Deferred deep linkStore, then the intended screen on first launchInvites, 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 exclude rules first.
  • appIDs entries are TEAMID.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 signingReport or keytool -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.

Firebase Dynamic Links was shut down in August 2025; existing page.link URLs no longer resolve. Options:

ApproachNotes
Attribution/linking vendor (Branch, AppsFlyer OneLink, Adjust)Handles install attribution and first-open routing on both platforms; review privacy disclosures
Android Play Install Referrer APIDeterministic: pass referrer= on the Play Store URL, read it on first launch
Account-based claimStore the intent server-side keyed to email/phone; after sign-up, fetch and route. Deterministic and private
iOS clipboard handoffTriggers 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
CaseExpected
App installed, cold startOpens the exact screen
App running in backgroundNavigates without duplicating screens
Signed outSign-in, then the target screen
Excluded path (/account/delete)Opens the website
App not installedWebsite renders content or store landing
Unknown/forbidden IDNot-found screen in app

Verification

  1. The script passes for every link domain against the production app ID, package, and Play signing fingerprint.
  2. pm get-app-links shows all domains verified on a release build from Play (internal testing track).
  3. 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 219 lines · 9.7 KB
#!/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.

esc