Deeplinks
August 20, 2026 · View on GitHub
Deeplinks are the shared URL contract that lets every ADE surface — desktop, ADE Code TUI, iOS, the marketing site, and external tools (Linear, GitHub PR descriptions, chat apps) — point at the same lane, work session, branch, PR, or Linear issue. Two forms carry identical semantics:
ade://lane/<uuid>
ade://session/<id>[?lane=<lane-uuid>&event=<seq>&offset=<bytes>]
ade://file/<repo-relative-path>[?line=<n>&lane=<lane-uuid>]
ade://commit/<sha>[?lane=<lane-uuid>]
ade://artifact/<artifact-id>
ade://repo/<owner>/<repo>/branch/<branch>[?pr=<n>]
ade://pr/<owner>/<repo>/<number>
ade://linear-issue/<ADE-123>[?branch=<branch>]
https://ade-app.dev/open?type=lane&id=<uuid>
https://ade-app.dev/open?type=session&id=<id>[&lane=<lane-uuid>&event=<seq>&offset=<bytes>]
https://ade-app.dev/open?type=file&path=<repo-relative-path>[&line=<n>&lane=<lane-uuid>]
https://ade-app.dev/open?type=commit&sha=<sha>[&lane=<lane-uuid>]
https://ade-app.dev/open?type=artifact&id=<artifact-id>
https://ade-app.dev/open?type=branch&repo=<owner>/<repo>&branch=<branch>[&pr=<n>]
https://ade-app.dev/open?type=pr&repo=<owner>/<repo>&number=<n>
https://ade-app.dev/open?type=linear-issue&issue=<ADE-123>[&branch=<branch>]
Machine-local targets (lane / session / commit / artifact) additionally carry a
portable envelope as query params — repo=<owner>/<repo>,
branch=<ref>, pr=<n>, linear=<ADE-123> — populated by builders with
whatever they know at mint time and parsed leniently (a malformed component is
dropped, never failing the link). A receiver that cannot resolve the primary id
uses the envelope for real fallbacks; see "Portable envelopes and the
resolution ladder" below.
Links minted from an Activity item additionally carry ownership —
accountMachineKey and projectId — so a receiver can route to the machine that
owns the work instead of guessing. On iOS this is also what makes a link
openable against a sleeping Mac: the phone reads accountMachineKey, sees the
machine announced sleep, and offers to wake it rather than failing the open (see
iOS companion → Waking a sleeping Mac).
A link that arrives without the key can have its owner recovered from local
attention/workspace snapshots, but only when the link came from outside the
app — resolving an owner during cold-launch restore would turn a plain relaunch
into a machine transition. Two rules govern what goes in projectId:
- Prefer the project's machine-independent canonical id
(
deriveProjectId(rootPath), theproject_<hash>form), because a link is opened by whichever machine the user happens to be on and the publisher's own database uuid resolves nowhere but the publisher. - Never stamp the absolute
projectRoot. ADE links are meant to be pasted into PR descriptions, Linear issues, and Slack, and aprojectRoot=/Users/<name>/Projects/<client>parameter leaks the local username and directory layout to every reader. Nothing is lost: the canonical id is the hash of that root, and a receiver recomputes it from each root it knows, matching exactly where a path comparison would have.
projectRoot is still parsed — it is what rescues an older link whose
projectId is the publishing machine's private uuid — and an over-long value is
dropped rather than failing the whole link, since no link minted today carries it.
The HTTPS form lives on apps/web (Vercel) and acts as a marketing landing
page plus an OS-level upgrade into the ade:// form when an ADE client is
registered. The ade:// form routes directly through the OS to the running
desktop process (or starts it cold). Both forms parse to the same
AppNavigationTarget shape and dispatch through IPC.appNavigate.
Source file map
Shared contract:
apps/desktop/src/shared/deeplinks.ts— builder + parser shared across main, renderer, ADE CLI, and the web/openAPI route. Validates UUIDs, GitHub owner/repo, Linear issue identifiers, branch refs (rejects traversal, control chars, trailing.lock), repo-relative file paths, commit shas, and session anchors. ExportsbuildDeeplink,parseDeeplink,looksLikeAdeDeeplink, anddescribeTargetplus theDeeplinkTargetunion (lane | session | file | commit | artifact | branch | pr | linear-issue) and theDeeplinkEnvelopeshape.apps/desktop/src/shared/githubRemote.ts— one GitHub remote-URL parser (git@and https forms) shared by the main-process repo resolver and the renderer's active-project repo check.apps/desktop/src/shared/adeDeeplinkFooter.ts— renders the branded "Open in ADE" footer block (markdown + small HTML subset) appended to GitHub PR descriptions and reused as Linear attachment subtitle. Idempotent: the block is wrapped in<!-- ade:link v=1 ... -->/<!-- /ade:link -->markers so subsequent renders replace in place instead of appending duplicates. Used byprService.tswhen creating or updating PRs.apps/desktop/src/shared/types/core.ts—AppNavigationTarget/AppNavigationRequest/AppNavigationResultcarry the parsed deeplink payload across IPC. Targets coverlane,chat/work(withevent/offsetanchors and the envelope),file,commit,artifact,pr(with optional repoOwner/repoName for not-yet-local PRs),branch(cross-machine send-to-mac payload),linear-issue, and the genericrouteshape; plusProjectFindForRepoArgs/Resultfor the catalog lookup.
Desktop renderer — resolution ladder + anchors:
apps/desktop/src/renderer/components/app/App.tsx—AppNavigationBridge.dispatchTargetowns the resolution ladder (local → switch-project → foreign card) and the file-path → lane-worktree resolution;InboundDeeplinkModal.tsxrenders the branch / foreign / switch-project cards.apps/desktop/src/main/services/projects/repoProjectResolver.ts— backs theproject.findForRepoIPC: parses recent projects' git origin from.git/config(no git subprocess), cached by config mtime.apps/desktop/src/renderer/components/terminals/pendingSessionAnchors.ts— one-shot per-session anchor queue between navigation (URL effect inuseWorkSessions, ⌘K palette) and the content surfaces (AgentChatMessageListscroll-to-sequence + highlight,TerminalViewreplay byte-fraction scroll).
Desktop main process — protocol handler:
apps/desktop/src/main/services/deeplinks/protocolHandler.ts— registers ADE as the OS handler for theade://scheme, acquires the single-instance lock so a secondopen ade://...reuses the running window, listens foropen-url(macOS) andsecond-instance(Win/Linux), buffers URLs received beforeapp.whenReady(), and on dispatch parses the URL, maps it to anAppNavigationTarget, and forwards through the caller-supplied dispatcher.main.tswires the dispatcher to focus the most-suitableBrowserWindowandwebContents.send(IPC.appNavigate, …).handleDeeplinkUrlis also re-used by the iOS Send-to-Mac sync command (syncRemoteCommandService.ts'sdeeplinks.open). Itsloghook is wired to the machine logger (~/.ade/runtime/desktop-main.jsonl), not a project one — all of this happens before a project can be open — and the branch that loses the single-instance lock and quits callsflushLogfirst, sodeeplink.single_instance.lock_lostsurvives the exit.apps/desktop/src/main/services/deeplinks/projectNavigationWindowSelection.ts— pure selection helper for project-scoped navigation. It first prefers a window whose active project already matches the target root, then a window with the target root open in a background project tab, asking main to activate that project before dispatching. Only when no existing window/tab can own the project does main open a new ADE window. It also owns the rule for a remote project opened from Activity: never the window the user is working in. Binding a remote project replaces that window's global project context, so an item about another machine opens elsewhere or in a new window rather than throwing away what was on screen.apps/desktop/src/main/services/deeplinks/ownerAwareNavigation.ts— routes an ownership-carrying target (work / chat / PR) to its owning machine before the ordinary focused-window fallback, returning false only for machine-unscoped navigation.apps/desktop/src/main/services/deeplinks/localProjectResolution.ts— resolves a link's project against this machine's own projects. Three id spaces reach a deeplink handler — a canonicalproject_<hash>from a modern link, a publishing machine's private uuid from an older one, and this machine's own private uuids — so neither an id match nor a path match alone is honest. It tries exact id, then the root path the link carried, then a recomputed canonical id, and returnsnullas a real answer so the caller routes to the owning machine instead of guessing. Its remote twin isresolveRemoteProjectBindinginservices/ipc/runtimeBridge.ts; keep the two in step.apps/desktop/src/main/main.ts— callsregisterAdeProtocolHandler({...})beforeapp.whenReady()so cold-start URLs aren't lost. The iOSdeeplinks.openpath calls the project-scoped dispatcher for the sync host's project root so Send-to-Mac targets the paired project's window/tab instead of whichever ADE window is focused locally.
Desktop main process — PR footer integration:
apps/desktop/src/main/services/prs/prService.tscallsensureAdeDeeplinkFooterwhen creating, updating, and re-rendering PR descriptions so the branded block always reflects the current branch + PR number. Re-render fires a follow-up PATCH once the PR number is known.apps/desktop/src/main/services/cto/linearLaneCardService.tsbuilds the same deeplink target when posting Linear attachments so Linear cards can open ADE lanes, PRs, Work sessions, or the Linear pane via the cross-machinehttps://ade-app.dev/open?...URL instead of a Mac-only path.
ADE CLI — outbound + inbound:
apps/ade-cli/src/commands/deeplinks.ts—ade open,ade link, andade linear installsubcommands.ade open <url>invokes the OS opener (open/xdg-open/rundll32.exe url.dll,FileProtocolHandler). Windows passes the URL as one direct process argument so OAuth query characters never entercmd.exe. The opener receives only a validatedade://orhttps://ade-app.dev/open?...URL, which routes back through the registered protocol handler. The--linear-issue <id> --branch <branch>form is what Linear's "Open issue in coding tool" entry passes; the receiving install opens the Linear pane to that issue, or shows a setup state if the project has not connected Linear yet.ade link …builds a deeplink for a lane / work session / branch / PR / Linear issue and copies it to the clipboard.--adeemits the custom scheme; the default is the HTTPS form;--webemits the hosted web client form (https://app.ade-app.dev/open?..., viabuildWebClientUrl) and cannot be combined with--ade. Round-trip form (ade link <url>) re-emits a parsed URL in the chosen form, and the round-trip validation gate also accepts a web-client URL.ade linear installwrites~/.linear/coding-tools.jsonso Linear's "Open issue in coding tool" dropdown can launch ADE. Backs up the previous file alongside.
apps/ade-cli/src/cli.tsandapps/ade-cli/src/adeRpcServer.ts— PR creation formatters/tools include the ADE HTTPS PR URL next to the GitHub URL when the PR number and repo are known. Agents should use thatadeUrlin final handoffs; if a PR was adopted through another path,ade link pr <owner/repo> <number> --no-clipboardmints the same URL.apps/ade-cli/src/tuiClient/deeplinkRow.ts— pure helper used by the TUI'sCtrl+Ykeybinding. Resolves the focused row (lane / PR) to a canonicalade://URL, including parsing GitHub PR URLs to lift owner/repo/number when the right-pane only carries the URL.buildWebClientUrlForRowin the same file resolves the focused row to the hosted web-client form (https://app.ade-app.dev/open?...) instead.apps/ade-cli/src/tuiClient/keybindings/index.ts— registerscopy_deeplinksoCtrl+Yover a highlighted lane or PR row builds and copies theade://link viatuiClient/app.tsx. The bindable action set also includesapp:copyAdeWebLink, which copies the web-client form of the focused row instead.
Apps/web — landing page + OG unfurl:
apps/web/src/app/pages/OpenPage.tsx— React SPA page mounted at/open. Reads the same query shape as the parser, attempts theade://upgrade, and falls back to a download / install card with the parsed target described inline.apps/web/api/open.ts— Vercel serverless function for/open. Self- fetches/index.html, rewrites<title>,og:*, andtwitter:*meta tags from query params so chat-app unfurlers (Slack, Discord, iMessage, Gmail, Linear) show a rich card without executing JavaScript. Cache:public, max-age=600, stale-while-revalidate=86400.apps/web/vercel.json— adds/open → /api/openrewrite ahead of the catch-all SPA rewrite.
iOS — inbound deeplinks, Universal Links, outbound link minting, and Send-to-Mac:
apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift— outbound link minting on the phone: an envelope-aware builder mirroring the TSbuildDeeplinkfor the shapes iOS mints (lane / session / branch / PR). Lane detail's "Copy ADE lane link" / "Copy branch link", the Work session copy-link, and PR detail's "Copy ADE link" all mint https-form links with envelope params (branch links resolve owner/repo from a linked PR; with no GitHub remote the lane link is copied instead, with a notice).apps/ios/ADE/App/DeepLinkRouter.swift— parses inboundade://URLs and the https/openmirror. Session links (both forms) and compactade://pr/<n>navigate locally via.adeDeepLinkRequested; sessionevent/offsetanchors rideWorkSessionNavigationRequest(carried, not yet scrolled to — no route-level scroll hook exists on iOS). Lane / repo-branch / full-PR / linear-issue / file / commit / artifact shapes validate then post.adeSendToMacRequested.- Universal Links:
apps/ios/ADE/ADE.entitlementscarriesapplinks:ade-app.dev;ADEApp.swiftroutesNSUserActivityTypeBrowsingWebactivities into the router; the AASA file is served fromapps/web/public/.well-known/apple-app-site-association(appIDVQ372F39G6.com.ade.ios, claiming only/open*and/pair*) with anapplication/jsonheader set inapps/web/vercel.json. apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift— SwiftUI sheet bound to.adeSendToMacRequested. Parses the URL (including the portable envelope) into aSendToMacTargetfor a human-readable headline / detail, forwards the URL to a paired desktop through the sync command surface, and offers envelope fallbacks the phone can act on directly (open the PR on GitHub, open the Linear issue).apps/ade-cli/src/services/sync/syncRemoteCommandService.ts— receives the iOS "Send to your Mac" payload as thedeeplinks.opensync command and feeds the URL throughhandleDeeplinkUrlso the desktop dispatches it through the same parser as an OS-routedopen ade://..., with main scoping delivery to the paired sync service's project root.apps/ade-cli/src/services/sync/syncHostService.ts— exposes the samedeeplinks.opencommand from the ADE runtime so a phone paired directly to a headless runtime can still bounce a URL out through that runtime.
URL semantics
| Form | Target | Notes |
|---|---|---|
ade://lane/<uuid> | { kind: "lane", laneId, envelope? } | UUID v4 required. |
ade://session/<id>[?lane=<uuid>&event=<seq>&offset=<bytes>] | { kind: "session", sessionId, laneId?, event?, offset?, envelope? } | Local Work tab session link. Routes to /work with the selected session. event anchors the chat list to the message with that persisted envelope sequence (ordinal fallback when the full transcript is loaded); offset best-effort-positions a replayed terminal scrollback by byte fraction. |
ade://file/<repo-relative-path>[?line=<n>&lane=<uuid>] | { kind: "file", path, line?, laneId? } | Opens the Files editor at the path — inside the lane worktree when lane is present, else the project root — and reveals line. Traversal-safe: WHATWG URL normalization collapses .. in the ade:// path form and the validator rejects traversal/absolute paths in the https path= param. |
ade://commit/<sha>[?lane=<uuid>] | { kind: "commit", sha, laneId?, envelope? } | Opens the owning lane's detail (commitSha param). Foreign fallback: the envelope's repo yields a GitHub commit URL. 7–40 hex chars. |
ade://artifact/<id> | { kind: "artifact", artifactId, envelope? } | Local-only proof artifact; opens the history surface. |
ade://repo/<owner>/<repo>/branch/<branch>[?pr=<n>] | { kind: "branch", repoOwner, repoName, branch, prNumber? } | Cross-machine. Renderer routes to the lane that already owns the branch; otherwise it opens a create/import modal. PR-backed links use PR preflight, branch-only links fetch the remote branch and import it as a local lane. If no ADE project is open, the modal asks the user to open the matching project first. |
ade://pr/<owner>/<repo>/<number> | { kind: "pr", repoOwner, repoName, prNumber } | If the PR isn't yet local, the renderer jumps to the PRs tab pre-filtered or falls back to the create-lane-from-branch flow. |
ade://linear-issue/<ADE-123>[?branch=<branch>] | { kind: "linear-issue", issueIdentifier, branch? } | Linear hand-off. Opens ADE's Linear pane focused to the issue. If no project is open or this project is not connected to Linear, ADE shows a setup modal with the next action. |
Validation lives in one place (shared/deeplinks.ts) so the parser, the
TUI builders, and the web /open handler agree on what counts as
malformed.
Portable envelopes and the resolution ladder
Lane, session, commit, and artifact ids are machine-local, so those links
carry a portable envelope (repo / branch / pr / linear query params)
populated at mint time. On open, the desktop resolves in a strict ladder
(AppNavigationBridge.dispatchTarget in App.tsx):
- Local — the id resolves in the active project → open it exactly, anchors included.
- Another known project — the envelope repo matches a different project
in the machine catalog (
project.findForRepoIPC →main/services/projects/repoProjectResolver.ts, which parses each recent project's git origin from.git/configwithout spawning git, cached by config mtime) → a card offers "Switch project and open", then re-dispatches the original target after the switch. - Foreign machine — the id resolves nowhere → a fallback-only card
("This chat/lane lives in
<owner>/<repo>on another machine") offering only the actions the envelope carries: create a lane from the branch (the existing branch-import flow), open the PR or commit on GitHub, open the Linear issue. There is deliberately no request-access or shared-transcript path.
All three cards are the one InboundDeeplinkModal (generalized to
branch | foreign | switch-project targets). Envelope parsing is lenient —
a malformed component is dropped so it can never break the primary target.
Session anchors
Search results and shared links can point inside a session. The anchor rides
the URL (event / offset), crosses IPC on the work/chat
AppNavigationTarget, and is handed one-shot to the session's content surface
via renderer/components/terminals/pendingSessionAnchors.ts (set by
useWorkSessions' URL effect and the ⌘K palette; consumed by
AgentChatMessageList — scroll + brief highlight — and TerminalView —
byte-fraction scroll in replay mode). The search index emits chat anchors as
the persisted envelope.sequence so a tail-paged transcript can resolve them
without loading full history.
End-to-end flow
┌────────────────────────────────────────────┐
│ Producer │
│ - ade link … (CLI clipboard) │
│ - Ctrl+Y in ade code (TUI copy) │
│ - PR description footer (auto-rendered) │
│ - Linear attachment (post on lane) │
│ - iOS share / chat app │
└────────────────────────────────────────────┘
│
ade:// URL or https://ade-app.dev/open?...
│
┌────────────────────────────────┼─────────────────────────────────┐
│ │ │
▼ ▼ ▼
Browser / chat unfurler Desktop OS handler iOS / paired phone
hits apps/web/api/open.ts (registerAdeProtocolHandler) (DeepLinkRouter.swift)
→ rich OG card + SPA → handleDeeplinkUrl → adeDeepLinkRequested
→ IPC.appNavigate → adeSendToMacRequested
→ sync deeplinks.open
on paired host
→ handleDeeplinkUrl
→ IPC.appNavigate
The parser and AppNavigationTarget mapping are the same in every case.
Delivery is project-aware: OS-routed desktop URLs use the active-window
dispatcher, while sync-originated Send-to-Mac URLs select the paired
project's existing window or background tab before IPC.appNavigate is
sent. The renderer's window.ade.app.onNavigate listener owns the routing
decision (open lane tab, jump to PR, prompt to create lane from branch,
resolve Linear issue to lane).
PR description footer
<!-- ade:link v=1 type=pr repo=<owner>/<repo> branch=<branch> num=<n> -->
<p>
<img src="https://ade-app.dev/logo.png" height="18" align="left" alt="ADE">
<strong>Open in ADE</strong>
· <a href="…">branch link</a>
· <a href="…">PR link</a>
</p>
<!-- /ade:link -->
prService.ts calls ensureAdeDeeplinkFooter on PR create and update.
Re-renders are idempotent — the marker comments mark the block boundary
so the next call replaces it in place rather than appending. When the PR
is first created, the footer initially carries the branch link only;
once the PR number is known, a follow-up patch re-renders the block
with the PR link included.
Agent closeout still includes explicit links. ade prs create and the
private create_pr_from_lane action return both githubUrl and
adeUrl when available, and the text formatter prints them as separate
rows. The GitHub PR body footer is automatic, but the final chat or TUI
handoff should still include the GitHub URL and the ADE HTTPS PR URL so
the user can jump directly to either GitHub or the ADE PRs tab.
Channel handling
Only the Stable channel claims ade:// as the OS-default handler.
Beta and Alpha builds still install the single-instance lock and the
open-url / second-instance listeners (so a manual duti binding
still routes deeplinks to them), but they skip
app.setAsDefaultProtocolClient so they don't fight Stable for the
binding on machines where multiple channels are installed.
Source builds default to the same skip behavior and do not expose an env
override to claim the system binding. They can still dispatch URLs that are
explicitly delivered to that process, but the OS-default ade:// handler is
reserved for the real packaged Stable desktop build. The gate lives in
apps/desktop/src/main/main.ts (packaged Stable channel detection) and the
registration mechanics live in protocolHandler.ts behind the
claimAsDefault option.
Gotchas
- Register the protocol handler before
app.whenReady(). Otherwise the cold-start URL on macOS / Windows gets dropped before the listener attaches.protocolHandler.tsbuffers URLs that arrive between registration andwhenReadyso they aren't lost. - Validate before dispatch. The parser rejects malformed inputs (non-UUID lane ids, traversal in branch refs, non-positive PR numbers, unknown hosts). Don't bypass it for "trusted" callers — Linear's template substitution can produce empty strings.
- The HTTPS form is the social form. When linking from chat apps,
emails, or anywhere a preview matters, prefer the
https://ade-app.dev/openshape so the unfurl works. Useade://when the target is guaranteed to be a machine with ADE installed (TUI copy, terminal share). - Linear hand-off doesn't carry the GitHub repo. The
linear-issueshape only has the identifier (ADE-123) and optionally Linear's generated branch name. The desktop opens the Linear pane to that issue inside the active project. From there the user can create a lane, start a chat, or connect Linear if this project has not been authorized yet. If no project is open, ADE shows a setup modal because the Linear issue identifier alone is not enough to choose a local repo. - Branch links are the portable lane form. Lane UUID links are local, but branch links can be shared across machines. If the receiver does not already have that lane, ADE fetches remotes and imports the branch as a local worktree-backed lane. Without an active project, the import modal stays read-only and asks the user to open the matching ADE project first.
- The PR footer is GitHub-flavored markdown, not full HTML. Only the
<p>,<img>,<a>,<strong>subset renders. Linear accepts the same subset.
Cross-links
- Pull requests — PR description rendering, footer integration.
- Sync and multi-device — the iOS Send-to-Mac sync command bounces deeplinks to a paired host.
- ADE Code —
ade open/ade link/ade linear installsubcommands,Ctrl+Ycopy. - System overview —
IPC.appNavigate, web /open route placement.