When Universal Links Break Silently — Catching Association Drift with an Agent-Run Verification Gate
Universal Links and App Links fall back to the browser with no error when your association files or entitlements drift apart. Here is a design that generates the files from one source of truth and hands weekly and pre-release checks to an agent.
For a while, one of the wallpaper apps I run on my own had a broken share link that never once opened the app. Tapping it just quietly launched Safari. No crash, no error log. I only found out through a user's message, and I burned half a day tracing it.
Universal Links on iOS and App Links on Android look like ordinary URLs when they work. Behind them, though, sit three independent declarations that must match exactly: the app's entitlements, the association file the OS fetches to verify ownership, and the app's own intent filters. The moment any one of them drifts, the link doesn't throw an exception — it falls back to "open in the browser," which looks perfectly normal. That is what makes this dangerous. When it breaks, nobody notices.
This article lays out that silent failure as a design problem: stop maintaining the declarations twice by hand, and hand the reconciliation — weekly and before every release — to an Antigravity agent.
Why it breaks silently — three declarations, three owners
Before a link can open, these three things are each declared in a different place. They drift because they live in different repositories, are owned by different people, and change at different times.
Upload key swap, Play App Signing certificate change
This gets worse when you let an agent reshape UI and routing. A new screen brings a new path in the app, but the published file update gets left behind. The generated code runs correctly, yet the link quietly detaches. Left alone, that asymmetry accumulates.
Make the route definition a single source of truth
The fix starts by refusing to hand-write link paths in three places. Collapse them into one definition file and generate the association files from it. At minimum, the app and the published files can no longer disagree by construction.
// link-routes.json — the one source of truth for link design{ "domain": "example.com", "ios": { "appID": "TEAMID123.com.example.wallpaper" }, "android": { "package": "com.example.wallpaper", "sha256": ["AA:BB:CC:...:99"] }, "routes": [ { "id": "collection", "path": "/c/*", "screen": "CollectionScreen" }, { "id": "wallpaper", "path": "/w/*", "screen": "WallpaperScreen" }, { "id": "invite", "path": "/invite/*", "screen": "InviteScreen" } ]}
From this one file, write out the iOS AASA and the Android assetlinks.json in code. The point is to never let anyone hand-edit the JSON.
// gen-association-files.mjs — zero dependencies; generate published files from routesimport { readFileSync, mkdirSync, writeFileSync } from "node:fs";const cfg = JSON.parse(readFileSync("link-routes.json", "utf8"));// iOS: apple-app-site-associationconst aasa = { applinks: { apps: [], details: [ { appID: cfg.ios.appID, paths: cfg.routes.map((r) => r.path), }, ], },};// Android: Digital Asset Linksconst assetlinks = [ { relation: ["delegate_permission/common.handle_all_urls"], target: { namespace: "android_app", package_name: cfg.android.package, sha256_cert_fingerprints: cfg.android.sha256, }, },];mkdirSync("public/.well-known", { recursive: true });// no extension, served as application/jsonwriteFileSync("public/.well-known/apple-app-site-association", JSON.stringify(aasa, null, 2));writeFileSync("public/.well-known/assetlinks.json", JSON.stringify(assetlinks, null, 2));console.log(`generated ${cfg.routes.length} routes for ${cfg.domain}`);
Keep the app-side declarations verifiable against the same definition. The iOS entitlement and the Android intent filter look like this:
On iOS you declare applinks:example.com under Associated Domains. If a single character of the domain or a missing subdomain is off here, the OS never fetches the association file, and the link falls silently back to the browser.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦How to stop hand-editing AASA and assetlinks.json and generate both from one route definition
✦A dependency-free verifier that reconciles entitlements, intent filters, and the published files
✦A two-stage gate for pre-release and weekly unattended runs, with six weeks of measured results
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Generating the files does not guarantee the server actually serves them correctly, or that they truly match the app's declaration. So build a check that fetches the published files and reconciles them with the definition. This is the body of both gates.
// verify-associations.mjs — fetch published files, confirm they match the definitionimport { readFileSync } from "node:fs";const cfg = JSON.parse(readFileSync("link-routes.json", "utf8"));const base = `https://${cfg.domain}/.well-known`;const problems = [];async function fetchJson(url) { const res = await fetch(url, { redirect: "manual" }); if (res.status !== 200) throw new Error(`HTTP ${res.status}`); const ct = res.headers.get("content-type") || ""; if (!ct.includes("application/json")) { problems.push(`${url}: Content-Type is ${ct} (not application/json)`); } return JSON.parse(await res.text());}try { const aasa = await fetchJson(`${base}/apple-app-site-association`); const detail = aasa.applinks?.details?.[0]; if (detail?.appID !== cfg.ios.appID) { problems.push(`AASA appID mismatch: published=${detail?.appID} defined=${cfg.ios.appID}`); } const publishedPaths = new Set(detail?.paths || []); for (const r of cfg.routes) { if (!publishedPaths.has(r.path)) problems.push(`AASA missing path: ${r.path}`); }} catch (e) { problems.push(`AASA fetch failed: ${e.message}`);}try { const links = await fetchJson(`${base}/assetlinks.json`); const target = links[0]?.target; if (target?.package_name !== cfg.android.package) { problems.push(`assetlinks package mismatch: ${target?.package_name}`); } const fps = new Set((target?.sha256_cert_fingerprints || []).map((s) => s.toUpperCase())); for (const want of cfg.android.sha256) { if (!fps.has(want.toUpperCase())) problems.push(`assetlinks missing fingerprint: ${want.slice(0, 17)}...`); }} catch (e) { problems.push(`assetlinks fetch failed: ${e.message}`);}if (problems.length) { console.error("Association drift detected:"); for (const p of problems) console.error(` - ${p}`); process.exit(1);}console.log("Associations match the definition");
If you can halt a release while this script returns exit 1, the app declaration and the published files never ship out of sync. In my own runs, adding the fingerprint check let me catch — ahead of time — the Android verification failure that tends to happen right after a Play App Signing certificate swap.
What to hand the agent, and how far
The verification itself is mechanical; the script handles it. Where an agent earns its keep is triage on failure and proposing the next move. But I keep irreversible actions — rewriting published files, changing domain settings — out of the agent's hands.
Task
Owner
Reason
Fetch published files, reconcile with definition
Script
Mechanical and deterministic; no judgment needed
Triage the likely cause of a failure
Agent
Good at narrowing across CDN, certificate, missing path
Propose route-definition updates
Agent then human approval
Proposals welcome; a human commits
DNS, CDN, certificate changes
Human only
Irreversible and wide-reaching
When you hand triage to an agent, pass the verifier's output verbatim — which path is missing, which fingerprint doesn't match — and cap it at three candidate causes. Let it guess broadly from an ambiguous prompt and it drifts toward plausible misdiagnoses, mistaking a CDN cache issue for a certificate problem. The tighter the constraint, the faster and more accurate the triage.
A two-stage gate: pre-release and weekly
Run the check at two moments. The pre-release gate runs the verifier just before you cut a release build and stops the build on failure. That secures the app declaration against the published files.
The second stage is a weekly unattended run. Published files break independently of your app releases. A domain move, a CDN configuration change, a certificate renewal — any of these can detach the association while you've touched nothing. So even on weeks with no release, something has to fetch the published files on a schedule and merely notify when they drift. Have Antigravity's CLI agent run the verifier weekly and leave a triage summary only when it returns exit 1; that catches the silent breakage within a few days.
What six weeks of running it taught me
I put this two-stage gate into a handful of my own wallpaper and calming apps and ran it for about six weeks. The measured results:
Metric
Before
After
How drift was discovered
User report (after the fact)
Weekly gate (ahead of time, 2 caught)
Time from break to discovery
Days to unknown
By the next weekly run at most
Time to triage the cause
Half a day
15–25 minutes
Both of the two cases I caught happened at moments unrelated to any release. One was a CDN cache setting that served a stale association file; the other was an assetlinks.json entry that hadn't kept up with a Play App Signing certificate renewal. Neither crashed, and both were the kind of failure I'd never have noticed if I'd left them alone.
If you're weighing where to start, begin with the weekly run of the verifier. You can add the generation step later, but building the ability to know it's broken first pays back more than the effort it costs. Links are assumed to just work, which is exactly why a silent detachment erodes trust so sharply. I'll be glad if it spares someone the same quiet half a day.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.