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.

  • Mobile & Desktop
  • $mobile-deep-links
  • 179 lines
  • 3 files
  • ~1,950 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- mobile-deep-links

Installs to ~/.agents/skills/mobile-deep-links/ for every project on this machine.

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.

~/.agents/skills/mobile-deep-links/SKILL.md

--- name: mobile-deep-links description: Sets up and debugs iOS universal links, Android App Links, custom URL schemes and deferred deep links - apple-app-site-association and… license: MIT ---

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

  1. Read AGENTS.md, then find the current link handling: SwiftUI .onOpenURL, scene delegate scene(_:continue:), Android intent filters in AndroidManifest.xml, Expo or React Navigation linking config, and any third-party SDK (Branch, AppsFlyer, Adjust).
  2. List every domain and path the product shares (emails, SMS, QR codes, marketing pages).
  3. Fetch the live association files (needs network) and run the validator below.
  4. 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

NeedUseAvoid
Links shared on the web, email, SMSUniversal links + App Links on httpsCustom schemes (no verification, any app can claim them)
OAuth redirect back into the appASWebAuthenticationSession callback scheme / Android App Link or claimed https redirectEmbedded web views
App-to-app handoff you controlCustom scheme as a secondary pathRelying on it from untrusted sources
Open the right screen after a fresh installDeferred deep link (see below)Device fingerprinting

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 exclude rules before broader includes.
  • Add applinks:example.com (and applinks:www.example.com if used) to the com.apple.developer.associated-domains entitlement 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=developer on devices with Associated Domains Development enabled in Developer settings.
  • Handle the link in SwiftUI with .onOpenURL { router.open($0) }, or in UIKit via NSUserActivityTypeBrowsingWeb in both scene(_:willConnectTo:options:) (cold start) and scene(_:continue:) (warm).
<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 autoVerify filter 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 onCreate and onNewIntent (or Compose Navigation deepLinks), and set launchMode so 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.

Firebase Dynamic Links was shut down in August 2025; any *.page.link URL is dead.

OptionWhen
Attribution provider (Branch, AppsFlyer OneLink, Adjust)Marketing needs campaign attribution across both stores
Android Play Install Referrer APIPass a route in the Play Store referrer parameter; read once on first launch; deterministic
iOS first-launch handoffNo 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.yamlgenerated from this page

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.py164 lines, 7.1 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem