ReaktorOverviewVision and roadmapContributing
Log inWorkbench
Documents
  • Overview
    • Using the documentation08-10-26
    • Framework Overview08-10-26
    • Reaktor Essays08-10-26
    • Astra Plan and Roadmap08-10-26
    • Contributing08-10-26
    • Documentation audit08-10-26
    • Graph markers for maintainers08-10-26
      • Graph theory08-10-26
      • Nodes08-10-26
      • Ports08-10-26
      • Hyperedges08-10-26
      • Nested graphs08-10-26
      • Subgraph views08-10-26
      • Kotlin graph language08-10-26
      • Distribution and change08-10-26
      • Precedents08-10-26
      • Interactive system atlas08-10-26
      • Mobile apps — worked graph designs08-10-26
      • Websites — worked graph designs08-10-26
      • Distributed systems — worked graph designs08-10-26
      • Bolt-on capabilities — worked graph designs08-10-26
      • Workflows — worked graph designs08-10-26
      • Multiplayer games — worked graph designs08-10-26
      • Robotic fleets — worked graph designs08-10-26
      • IoT — worked graph designs08-10-26
    • Architecture thesis and invariants08-10-26
    • Nodes, ports and hyperedge vocabulary08-10-26
    • Composition and execution semantics08-10-26
    • Compiler, source publication and consistency08-10-26
    • Shadow, evidence and region releases08-10-26
    • Authoring and industry precedents08-10-26
    • Cross-runtime systems and worked scenarios08-10-26
    • Impact, design obligations and qualification08-10-26
    • The engine and feature composition08-10-26
    • Graph model, protocols and computation08-10-26
    • Platforms, identity and polyglot execution08-10-26
    • Data authority, contracts and federation08-10-26
    • Build, compilation and resource budgets08-10-26
    • LiveUpdate, coordinator and human-agent authoring08-10-26
    • Assurance and adoption studies08-10-26
    • Precedents, evaluation and implementation sequence08-10-26
    • References, notation, exercises and source lineage08-10-26
    • Original page layouts08-10-26
      • Architecture and reading map08-10-26
      • Data Graph08-10-26
      • Current storage APIs08-10-26
      • Multitenancy08-10-26
      • Transactions and derived state08-10-26
      • Bus08-10-26
      • Mesh07-10-26
      • Queues and Workflows08-10-26
      • Work09-10-26
      • Data inspection08-10-26
      • Implementation and evidence08-10-26
      • BestBuds Schema Hardening08-10-26
      • Module Execution Plan11-10-26
      • Module Review11-10-26
      • Capability Runtime08-10-26
      • Core08-10-26
      • Service Layer08-10-26
      • Auth Architecture08-10-26
      • Deep Links08-10-26
      • Notifications08-10-26
      • Server08-10-26
      • Surface overview08-10-26
      • Application extensions08-10-26
      • Application patterns08-10-26
      • Contracts and interaction08-10-26
      • Design and ownership08-10-26
      • Desktop08-10-26
      • Dynamic UI and verification08-10-26
      • Foundations08-10-26
      • Implementation plan08-10-26
      • Motion and materials08-10-26
      • Performance and assets08-10-26
      • References08-10-26
      • Renderers and interop08-10-26
      • WebView runtime08-10-26
      • From Bytes to Mutable Memory08-10-26
      • Part I Bytes and Ownership08-10-26
      • Part II FlatBuffers08-10-26
      • Part III FlexBuffers08-10-26
      • Part IV Quality and Performance08-10-26
      • Part V LiveFlex Mutable Memory08-10-26
      • Appendix A Type Codes and Byte Equations08-10-26
      • Appendix B Exercise Solutions and Implementation Hints08-10-26
      • Appendix C Reading the Official Implementation08-10-26
      • Appendix G Sources and Edition Notes08-10-26
      • FlexBuffer08-10-26
      • FlexBuffer Plan08-10-26
      • Performance and stability review08-10-26
      • Appendix D Checked FlexBuffers Laboratory07-10-26
      • Appendix E Mutable Semantic Laboratory07-10-26
      • Appendix F FlatBuffers Table Laboratory07-10-26
      • Kotlin/CLR08-10-26
      • FFI08-10-26
      • FFI Design (merged)08-10-26
      • Graph Kernel08-10-26
      • Graph Visitors08-10-26
      • Actor Model08-10-26
      • Distributed Actors08-10-26
      • Hierarchical Graph UI08-10-26
      • Graph Protocol08-10-26
      • Graph Sources08-10-26
      • Graph Substrate Redesign08-10-26
      • Living Graph08-10-26
      • Component Lifecycle07-10-26
      • Formal Methods08-10-26
      • Stable Foundations 5.6 Ultra08-10-26
      • Verified Foundations Fable 508-10-26
      • Graph Refinement08-10-26
      • Build Tools08-10-26
      • CLI08-10-26
      • DevOps08-10-26
      • Dependeasy07-10-26
      • Cloud Control Plane08-10-26
      • Mega-Scale Strategy08-10-26
      • Reaktor Secrets08-10-26
      • Security Plan08-10-26
      • Operations overview08-10-26
      • Sources & document map08-10-26
      • Crashlytics and client evidence08-10-26
      • Graph evidence08-10-26
      • Observability08-10-26
      • Performance overview08-10-26
      • Analytics + Telemetry08-10-26
      • Instance diagnostics08-10-26
      • Load & resilience08-10-26
      • Profiling08-10-26
      • DevTools08-10-26
      • Tooling Layer08-10-26
      • Backends & Keploy08-10-26
      • Capture fabric08-10-26
      • Capture & replay08-10-26
      • Clients & agents08-10-26
      • Reaktor Shadow08-10-26
      • Shadow foundations08-10-26
      • Verification & capsules08-10-26
      • Shadow workspaces08-10-26
      • Delivery & runbooks08-10-26
      • Instrumentation Plan08-10-26
      • Connected panes08-10-26
    • Hangar08-10-26
    • Reaktor Arena08-10-26
    • Living Graph Super-IDE08-10-26
    • Cloud08-10-26
    • Desktop Master Plan08-10-26
    • Graph08-10-26
    • UI/UX Audit08-10-26
    • Visual Language08-10-26
    • Workbench Layers08-10-26
      • Agent efficiency08-10-26
      • Control Plane08-10-26
      • Conductor08-10-26
      • Agent Workspace08-10-26
      • Koog Workbench08-10-26
      • Koog Bot08-10-26
      • Browser-Local AI08-10-26
      • Gemini DevOps Agent08-10-26
    • Nexergy on Reaktor (Claude)08-10-26
    • Nexergy Energy08-10-26
    • CompanyOS Migration08-10-26
    • Manna Designs08-10-26
    • Manna HLD08-10-26
    • Manna Kotlin migration08-10-26
      • Claude Critic08-10-26
      • Codex Critic08-10-26
      • Competitive Landscape08-10-26
      • Codex Roadmap08-10-26
      • Implementation Plan08-10-26
      • Strategic Roadmap08-10-26
      • Claude Dev Env08-10-26
      • Codex Dev Env08-10-26
      • Engine Report08-10-26
      • Flow Analysis08-10-26
      • Performance history08-10-26
      • Workbench Gaps08-10-26
      • Atlas Source Review (Fable 5)08-10-26
      • Desktop v308-10-26
      • Graph Migration08-10-26
      • Shadow history08-10-26
Reaktor / Deep Link Integration

Deep Link Integration

Design and rollout plan for a graph-native, visitor-driven deep link layer, with BestBuds as first consumer.

  • Owner Reaktor framework
  • Audience framework + app teams
  • Status design, unimplemented
  • Scope Android · iOS · Web · Desktop

Status — 13 September 2026. Nothing here is built.

Re-checked against both working trees. There is no DeepLinkResolver, no DeepLink type, and no navigation/deeplink package anywhere in reaktor/. Every gap in §2.2 is still open, verbatim:

  • appAndroid/src/main/AndroidManifest.xml declares one intent-filter, MAIN + LAUNCHER. No VIEW, no android:scheme, no autoVerify.
  • appDarwin/iosApp/Info.plist declares one CFBundleURLTypes entry, and its only scheme is the Google OAuth reversed client id.
  • No assetlinks.json and no apple-app-site-association is served from any target.

WebNavigationBridge on JS remains the only working resolver, and it still only matches window.location.pathname against a map it builds by recursing into containers.

The building blocks moved module. RouteNode, NavigationCapability and the visitor framework now live in reaktor-graph-runtime; reaktor-graph is the Compose layer above it and re-exports them with api(...). RoutePattern is still in reaktor-io. The paths below are corrected; the design is unchanged.

1. Executive summary

Reaktor already owns everything a deep link layer needs: RoutePattern compiles /{param} templates into a regex with named groups; RouteNode is a graph-attached destination; NavigationCapability drives an ObservableStack<BackStackEntry>; WebNavigationBridge already proves that an external URL can be resolved against the graph and pushed.

What is missing is the platform-agnostic glue. This document proposes a single DeepLinkResolver in commonMain that uses visitors over the graph to (a) discover all reachable routes including those nested in ContainerNode children, (b) plan a synthetic back stack so a link to /chats/abc still leaves /home and /chats underneath it, (c) run authorization guards declared as capabilities on the route or its ancestor graphs, and (d) defer the link until auth completes when needed.

Each platform supplies a thin bridge that converts its native entry point (Android Intent, iOS NSURL / NSUserActivity, browser window.location, JVM CLI args) into a string URI and hands it to the resolver. BestBuds adds an intent-filter, a CFBundleURLTypes entry, and a Universal Links / App Links pair served from the existing Cloudflare worker.

tip

Why visitors: the resolver does four distinct read-only graph traversals (index build, lookup, plan synthesis, guard collection). All four fit the existing Visitor + Selector pattern in reaktor-graph-runtime/.../visitor/Visitor.kt, so they get cross-graph (ContainerNode) descent and structural awareness for free.

2. Current state

What is already in place, what is missing, and where the partial work lives.

2.1 What works

PieceFileNotes
RoutePattern readyreaktor-io/.../network/RoutePattern.ktParses /{name} templates into regex + paramNames. Already supports getParams(MatchResult) and fill(params) round-trip.
RouteNode + NavigationEdge readyreaktor-graph-runtime/.../core/node/RouteNode.ktEach screen is a node with a pattern; navigation between two is a typed edge with a Payload.
NavigationCapability readyreaktor-graph-runtime/.../navigation/NavigationCapability.ktPush/Replace/Pop/Return mutate ObservableStack. Cross-graph forwards bubble up through parentGraph and resolve via findContainerForGraph.
Visitor + selectors readyreaktor-graph-runtime/.../visitor/Visitor.ktStructuralSelector descends into ContainerNode.graphs; RoutingSelector filters to RouteNode + ControllerNode; both reusable.
WebNavigationBridge prototypereaktor-graph/src/jsMain/.../WebNavigationBridge.ktBuilds a Map<RoutePattern, RouteNode> by recursing into containers, matches window.location.pathname, dispatches Push(sentinel.edge(routeNode), Payload(params)). Generalises into the new resolver.

2.2 What's missing

GapWhereImpact
Android intent-filter missingbestbuds/targets/appAndroid/src/main/AndroidManifest.xml — still only MAIN/LAUNCHER on 13 Sep 2026OS will not route any URL to the app.
iOS URL scheme & Universal Links missingbestbuds/targets/appDarwin/iosApp/Info.plist — only Google OAuth scheme; BestBudsApplication.application(openURL:) hands every URL to GIDSignIn.No custom or universal URL is ever delivered to the graph.
Desktop URI args missingbestbuds/targets/reaktorDesktop/src/main/kotlin/Main.kt — fun main() ignores argsopen bestbuds://... on macOS will not surface anywhere.
.well-known association files missingNo assetlinks.json / apple-app-site-association servedCannot upgrade to App Links / Universal Links — every link still asks "open in which app?"
Route-level auth guards missingDecision happens inside StartBinding; a deep link to /chats/abc would render the chat regardless of session.Privacy/UX hazard. No place to attach the guard today.
Common DeepLinkResolver missingThe web bridge is the only thing that resembles one, and only on JS.Each platform would otherwise reinvent matching, planning, and guard logic.

3. Goals & non-goals

Goals

  • One DeepLinkResolver in commonMain, four thin platform bridges.
  • Build the route index from the live graph via a visitor — no parallel registry to maintain.
  • Support custom scheme (bestbuds://...) and Universal/App Links (https://bestbuds.app/...) with one implementation.
  • Synthesise a logical back stack so back from a deep link returns to the natural parent.
  • Defer a link cleanly when the user must authenticate first.
  • Round-trip: given a route + payload, produce a shareable URL.
  • Emit a machine-readable schema of the URI surface (for docs, server, and AppLink autoVerify).

Non-goals

  • Server-side rendering of deep linked content.
  • Branch / AppsFlyer style attribution. The resolver is content-routing only; analytics tags can ride in query params.
  • Reordering the existing NavCommand sealed hierarchy. Deep links produce Push/Replace commands; the resolver does not introduce a new command kind.
  • Replacing WebNavigationBridge's history sync. The bridge becomes the resolver's caller on JS — history sync stays put.

4. URI model

One canonical representation, multiple input syntaxes.

// commonMain — reaktor-graph-runtime/.../navigation/deeplink/DeepLink.kt
@JsExport
data class DeepLink(
    val scheme: String,                       // "bestbuds" | "https" | ...
    val host: String?,                        // null for custom-scheme paths
    val path: String,                         // "/chats/abc123" (always leading /)
    val query: Map<String, String> = emptyMap(),
    val fragment: String? = null,
    val source: Source = Source.Cold,         // Cold launch, Foreground, NotificationTap, ShareSheet
) {
    enum class Source { Cold, Foreground, NotificationTap, ShareSheet, Browser, Other }

    companion object {
        fun parse(uri: String): DeepLink? = /* RFC 3986 split */
    }
}

Two accepted URI shapes converge to the same DeepLink:

bestbuds://chats/abc123?ref=push
        scheme=bestbuds  host=null  path=/chats/abc123  query={ref:push}

https://bestbuds.app/chats/abc123?ref=push
        scheme=https     host=bestbuds.app  path=/chats/abc123  query={ref:push}

Path matching ignores host once a configured allow-list passes. The allow-list lives in DeepLinkConfig:

@JsExport
data class DeepLinkConfig(
    val customScheme: String = "bestbuds",
    val universalHosts: Set<String> = setOf("bestbuds.app", "app.bestbuds.ai"),
    val canonicalHost: String = "bestbuds.app",   // for outgoing share URLs
    val rejectUnknownHost: Boolean = true
)

5. Architecture

The resolver is a pure function of (uri, rootGraph) -> Resolution. Platform bridges decide when to call it; NavigationCapability executes the resulting plan.

┌────────────────────────── Platform bridge layer ──────────────────────────┐
│ AndroidDeepLinkBridge     DarwinDeepLinkBridge     DesktopDeepLinkBridge  │
│ └─ onNewIntent(i)         └─ application(openURL)  └─ args[0] / open URL  │
│          │                         │                         │            │
│          ▼                         ▼                         ▼            │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ WebNavigationBridge (already-extant JS)                               │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────┬───────────────────────────────────┘
                                        ▼
┌────────────────────────── commonMain — reaktor-graph ─────────────────────┐
│ DeepLink.parse(uri)                                                       │
│        │                                                                  │
│        ▼                                                                  │
│ DeepLinkResolver.resolve(link, rootGraph)                                 │
│        │                                                                  │
│        ├────────────┬─────────────┬───────────────────┬─────────────────┐│
│        ▼            ▼             ▼                   ▼                 ▼│
│  RouteIndex     RouteMatch  BackStackPlanner    GuardCollector   ActionLookup
│  Visitor        Visitor     Visitor             Visitor          Visitor │
│        │            │             │                   │                 ││
│        └────────────┴─────────────┴────────┬──────────┴─────────────────┘│
│                                            ▼                              │
│                                      Resolution                            │
│                                      /    |    \                           │
│                                    Plan Gated Unknown                      │
│                                            │                              │
│                                            ▼                              │
│                                  rootGraph.dispatch(...)                  │
└─────────────────────────────────────────────────────────────────────────────┘

Module placement: the code belongs in reaktor-graph-runtime, alongside the rest of the navigation kernel. Graph, RouteNode and NavCommand are already there, and RoutePattern comes from reaktor-io, which the runtime already depends on. A separate module would re-export all of it for nothing.

reaktor-graph-runtime/src/commonMain/.../navigation/deeplink/
├── DeepLink.kt              // URI value type + parser
├── DeepLinkConfig.kt        // schemes, hosts, canonical
├── DeepLinkResolver.kt      // public API
├── Resolution.kt            // sealed Plan / Gated / Unknown / Action
├── DeepLinkGuard.kt         // capability + sealed GuardResult
├── DeepLinkAction.kt        // non-route handlers
├── PendingDeepLink.kt       // queue across auth boundary
├── visitor/
│   ├── RouteIndexVisitor.kt
│   ├── BackStackPlannerVisitor.kt
│   ├── GuardCollectorVisitor.kt
│   └── SchemaEmitterVisitor.kt
reaktor-graph/src/androidMain/.../navigation/deeplink/AndroidDeepLinkBridge.kt
reaktor-graph/src/iosMain/.../navigation/deeplink/DarwinDeepLinkBridge.kt
reaktor-graph/src/jvmMain/.../navigation/deeplink/DesktopDeepLinkBridge.kt
reaktor-graph/src/jsMain/.../navigation/deeplink/WebDeepLinkBridge.kt   // wraps existing

6. The four visitors

Each pass extends the existing open class Visitor in reaktor-graph/visitor/Visitor.kt and uses StructuralSelector as its selector so it descends into ContainerNode.graphs automatically — no hand-rolled recursion as in today's WebNavigationBridge.buildRouteIndex.

6.1 RouteIndexVisitor

Walks the root graph once at startup (or on invalidation). Produces:

  • byPattern: Map<RoutePattern, RouteNode<*, *>> — for matching.
  • byNode: Map<RouteNode<*, *>, RoutePath> — for outgoing URL building.
  • parentChain: Map<RouteNode<*, *>, List<RouteNode<*, *>>> — for back-stack planning.
class RouteIndexVisitor : Visitor() {
    val byPattern = mutableMapOf<RoutePattern, RouteNode<*, *>>()
    val parentChain = mutableMapOf<RouteNode<*, *>, MutableList<RouteNode<*, *>>>()
    private val containerStack = ArrayDeque<RouteNode<*, *>>()

    override fun visitRouteNode(node: RouteNode<*, *>): ExitScope {
        if (node.pattern.original.isNotEmpty()) {
            byPattern[node.pattern] = node
            parentChain[node] = containerStack.toMutableList()
        }
        return NoOpExit
    }

    override fun visitContainerNode(node: ContainerNode): ExitScope {
        containerStack.addLast(node.route)
        return { containerStack.removeLast() }
    }
}

Caching: the index is invalidated only on graph mutation, which we already track via Graph.attach/detach. Add a version counter on Graph and rebuild lazily.

6.2 BackStackPlannerVisitor

Given a matched RouteNode, returns an ordered list of Push commands so the back stack looks logical. For /chats/abc the plan is:

[ Push(sentinel->/home,        Payload()),
  Push(/home->/chats,          Payload()),
  Push(/chats->/chats/{id},    ChatPayload(id="abc"))  ]

The planner consults parentChain from the index and walks every entry's existing NavigationEdge (lookup via start.edge(end), the same method WebNavigationBridge uses today). When no edge exists between two ancestors, the planner falls back to graph.sentinel.edge(node) — the same fallback the JS bridge uses on cold launch.

6.3 GuardCollectorVisitor

Walks from the matched route up to the root, harvesting DeepLinkGuard capabilities attached to any Graph or RouteNode on the path. Returns them in outermost-first order so the broadest gate runs first.

interface DeepLinkGuard : Capability {
    suspend fun evaluate(link: DeepLink, route: RouteNode<*, *>): GuardResult
}
sealed interface GuardResult {
    data object Allow : GuardResult
    data class Redirect(val to: RoutePattern, val payload: Payload) : GuardResult
    data class WaitFor(val signal: Flow<Boolean>) : GuardResult   // auth pending
    data class Deny(val reason: String) : GuardResult
}

Auth gating is one specialization: AuthGuard in reaktor-auth returns WaitFor(auth.isLoggedIn) while a user is being authenticated, and Allow after.

6.4 SchemaEmitterVisitor

Build-time only. Walks every RouteNode and emits JSON consumed by:

  • assetlinks.json autoVerify generation.
  • The bot/share server, so other services can shareUrlFor("/chats/{id}", {id: "abc"}) without depending on the graph.
  • Generated docs for the URI surface (this very HTML can be regenerated from it).

7. Common API surface

// commonMain — DeepLinkResolver.kt
@JsExport
class DeepLinkResolver(
    private val rootGraph: Graph,
    private val config: DeepLinkConfig,
    private val pending: PendingDeepLink,
) {
    private val index by lazy { RouteIndexVisitor().also { it.walk(rootGraph) } }

    suspend fun resolve(uri: String): Resolution {
        val link = DeepLink.parse(uri) ?: return Resolution.Unknown(uri, "parse-failed")
        return resolve(link)
    }

    suspend fun resolve(link: DeepLink): Resolution {
        if (!link.acceptedBy(config)) return Resolution.Unknown(link.toString(), "host-rejected")

        val matched = matchPath(link.path, link.query) ?: return Resolution.Unknown(link.toString(), "no-route")

        val guards = GuardCollectorVisitor(matched.node).also { it.walk(rootGraph) }.guards
        for (g in guards) when (val r = g.evaluate(link, matched.node)) {
            GuardResult.Allow -> {}
            is GuardResult.Redirect -> return Resolution.Plan(plan(r.to.toRoute(), r.payload, link))
            is GuardResult.WaitFor  -> { pending.enqueue(link); return Resolution.Gated(link, r.signal) }
            is GuardResult.Deny     -> return Resolution.Unknown(link.toString(), "denied: ${r.reason}")
        }

        return Resolution.Plan(plan(matched.node, matched.payload, link))
    }

    fun apply(plan: Resolution.Plan) = plan.steps.forEach(rootGraph::dispatch)

    fun urlFor(route: RouteNode<*, *>, payload: Payload): String =
        "https://${config.canonicalHost}${route.pattern.fill(payload.routeParams)}"
}
// Resolution.kt
@JsExport
sealed interface Resolution {
    data class Plan(val steps: List<NavCommand>) : Resolution
    data class Gated(val link: DeepLink, val until: Flow<Boolean>) : Resolution
    data class Action(val handler: suspend () -> Unit) : Resolution
    data class Unknown(val uri: String, val reason: String) : Resolution
}

Each platform bridge is a five-line glue:

// platform pseudo-code, same shape on all four
val resolution = resolver.resolve(uri)
when (resolution) {
    is Resolution.Plan    -> resolver.apply(resolution)
    is Resolution.Gated   -> /* stays queued; UI shows nothing extra */
    is Resolution.Action  -> resolution.handler()
    is Resolution.Unknown -> log.w("unhandled deep link: ${resolution.uri} (${resolution.reason})")
}

8. Android bridge

8.1 Manifest

bestbuds/targets/appAndroid/src/main/AndroidManifest.xml

<activity android:name="ai.bestbuds.app.BestBudsActivity"
          android:exported="true"
          android:launchMode="singleTask">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    <!-- Custom scheme: works on cold launch, no autoVerify needed -->
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="bestbuds" />
    </intent-filter>

    <!-- App Link (https): autoVerify off until /.well-known/assetlinks.json is live -->
    <intent-filter android:autoVerify="false">
        <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="bestbuds.app" />
        <data android:scheme="https" android:host="app.bestbuds.ai" />
    </intent-filter>
</activity>

Once the worker is serving the association file, flip autoVerify to true.

8.2 Activity wiring

bestbuds/modules/app/src/androidMain/.../BestBudsActivity.kt

override fun onCreate(state: Bundle?) {
    super.onCreate(state)
    // ... existing Reaktor.start { ... } ...
    Reaktor.DeepLink = AndroidDeepLinkBridge(BestBuds, resolver = DeepLinkResolver(BestBuds, config, pending))
    intent?.data?.let { Reaktor.DeepLink?.handle(it.toString(), DeepLink.Source.Cold) }
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    intent.data?.let { Reaktor.DeepLink?.handle(it.toString(), DeepLink.Source.Foreground) }
}

note

Why singleTask: without it, foreground deep links spawn a new activity instance and the existing back stack is lost. singleTask routes the URL to the existing BestBudsActivity through onNewIntent.

9. iOS bridge

9.1 Info.plist

bestbuds/targets/appDarwin/iosApp/Info.plist — add a second URL type alongside the existing Google OAuth entry:

<dict>
    <key>CFBundleTypeRole</key><string>Editor</string>
    <key>CFBundleURLName</key><string>ai.bestbuds.deeplink</string>
    <key>CFBundleURLSchemes</key>
    <array><string>bestbuds</string></array>
</dict>

Universal Links additionally require an Associated Domains capability with entries applinks:bestbuds.app and applinks:app.bestbuds.ai in the Xcode project (and the matching apple-app-site-association at the host — see §14).

9.2 AppDelegate wiring

The existing BestBudsApplication.application(_:openURL:options:) currently routes all URLs to GIDSignIn. Change to scheme-aware dispatch:

override fun application(application: UIApplication, openURL: NSURL, options: Map<Any?, *>): Boolean {
    if (openURL.scheme == "bestbuds") {
        Reaktor.DeepLink?.handle(openURL.absoluteString!!, DeepLink.Source.Foreground)
        return true
    }
    return handleGoogleSignInUrl(openURL, "openURL.options")
}

override fun application(
    application: UIApplication,
    continueUserActivity: NSUserActivity,
    restorationHandler: (List<*>?) -> Unit
): Boolean {
    if (continueUserActivity.activityType == NSUserActivityTypeBrowsingWeb) {
        val url = continueUserActivity.webpageURL ?: return false
        Reaktor.DeepLink?.handle(url.absoluteString!!, DeepLink.Source.Browser)
        return true
    }
    return false
}

Cold launch: read didFinishLaunchingWithOptions[UIApplicationLaunchOptionsURLKey] after Reaktor.start { ... } resolves; if present, feed it to the bridge with Source.Cold.

10. Web bridge

The existing WebNavigationBridge already does 80% of this job — it discovers routes, matches paths, and dispatches Push. Refactor:

  • Replace its private buildRouteIndex recursion with RouteIndexVisitor.
  • Replace its private matchRoute with a call to DeepLinkResolver.resolve(window.location.href).
  • Keep its popstate listener and back-stack ⇒ history sync — that's a web-only concern unrelated to inbound deep links.

Effect: web stops carrying its own routing logic; it consumes the same resolver as native.

11. Desktop bridge

Two entry points to wire — bestbuds/targets/reaktorDesktop/src/main/kotlin/Main.kt:

fun main(args: Array<String>) {
    initReaktorDesktopFeatures()

    // Cold-launch URL (macOS: open bestbuds://..., Windows: protocol handler)
    val pendingArg = args.firstOrNull { it.startsWith("bestbuds://") || it.startsWith("https://bestbuds.") }

    application {
        // ... existing window setup ...
        LaunchedEffect(Unit) { pendingArg?.let { Reaktor.DeepLink?.handle(it, DeepLink.Source.Cold) } }
    }
}

For runtime URLs on macOS use AWT's Desktop.getDesktop().setOpenURIHandler { Reaktor.DeepLink?.handle(it.uri.toString(), DeepLink.Source.Foreground) }. The bestbuds custom scheme needs an Info.plist entry in the .app bundle — Compose Desktop's Conveyor packaging already supports custom Info.plist injection.

12. Auth guards & pending links

12.1 The flow

  1. Cold launch with bestbuds://chats/abc.
  2. Resolver finds /chats/{id}, matches param id=abc.
  3. GuardCollectorVisitor walks the parent chain — ChatGraph declares an AuthGuard via graph.attach(AuthGuard()) in its init block, so the guard is picked up.
  4. If auth.user.value == null, guard returns WaitFor(auth.isLoggedIn).
  5. Resolver enqueues the link in PendingDeepLink, returns Resolution.Gated; bridge does nothing extra.
  6. StartBinding finishes normally — onboarding or login.
  7. Once auth.isLoggedIn emits true, PendingDeepLink.collector re-runs resolver.resolve(link). This time the guard returns Allow and the plan is dispatched.

12.2 PendingDeepLink

class PendingDeepLink(private val resolver: () -> DeepLinkResolver) {
    private val pending = MutableStateFlow<DeepLink?>(null)
    fun enqueue(link: DeepLink) { pending.value = link }
    fun consume() { pending.value = null }

    suspend fun watch(scope: CoroutineScope, until: Flow<Boolean>) = scope.launch {
        until.first { it }
        pending.value?.let { link ->
            when (val r = resolver().resolve(link)) {
                is Resolution.Plan -> { resolver().apply(r); consume() }
                else -> { /* still gated, stay queued */ }
            }
        }
    }
}

Survives process death by persisting the queued link to KeyValueStore (already wired in BestBudsActivity).

12.3 Declaring a guard

class ChatGraph(parent: Graph? = null): Graph(parent), BottomNavigable {
    init {
        attach(AuthGuard())                    // every route in this graph is gated
        // ... existing route definitions ...
    }
}

// Or per-route:
val adminRoute = Route("/admin/{id}") { AdminBinding() }.guard(RoleGuard(Role.Admin))

13. Actions — non-route deep links

Not every link should add a screen. bestbuds://share/event?id=evt_42 opens a share sheet on top of whatever is current; bestbuds://refresh-feed just nudges a repository. These register as DeepLinkAction:

graph.registerAction("/share/event") { link ->
    val eventId = link.query["id"] ?: return@registerAction
    shareSheet.show(eventId)
}

The resolver consults the action registry before the route index, so an action with the same path takes precedence. Actions can also compose with a navigation plan — e.g., navigate to /events/{id} first, then show the share sheet on top.

14. .well-known association files

Serve from the existing Cloudflare worker. Place static files under bestbuds/cloud/.well-known/ and add a route in bestbuds/wrangler.json.

14.1 Android — assetlinks.json

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "ai.bestbuds.app",
    "sha256_cert_fingerprints": [
      "AA:BB:CC:DD:EE:FF:..."   // Play upload + Play app signing fingerprints
    ]
  }
}]

14.2 iOS — apple-app-site-association

{
  "applinks": {
    "details": [{
      "appIDs": ["TEAMID.ai.bestbuds.darwin"],
      "components": [
        { "/": "/chats/*",     "comment": "Chat threads" },
        { "/": "/events/*",    "comment": "Events" },
        { "/": "/profiles/*",  "comment": "User profiles" },
        { "/": "/onboarding",  "exclude": true }
      ]
    }]
  }
}

Both files are generated by SchemaEmitterVisitor + the published Android/iOS bundle IDs, so the list of allowed paths can never drift from the actual routes.

15. BestBuds wiring

The current routes (from bestbuds/modules/app/src/commonMain/.../BestBuds.kt) need almost no change. The graph already names them with the patterns we'd want as URLs:

Deep linkResolves toGuard
bestbuds://chatsChatGraph root, /chatsAuth
bestbuds://chats/{id}chatRouteAuth + session-member check
bestbuds://chats/profile/friend/{id}friendProfileRouteAuth
bestbuds://campaigns/currentCampaignGraphAuth
bestbuds://campaigns/discoverDiscoverGraphAuth
bestbuds://events/private / /events/publicEventGraph tabbed containerAuth
bestbuds://events/createcreateEventRoute action overlayAuth
bestbuds://friends/{id}friendProfileRoute in FriendGraphAuth
bestbuds://profiles/userprofileRouteAuth
bestbuds://onboardingonboardingBindingNone (must be reachable while logged-out)
bestbuds://dev / bestbuds://paletteDevGraph / PaletteGraphcanAccessDevTools()

The single tab-container link (/events/private vs /events/public) is the only non-trivial case — the existing EventGraph Container(...) wraps both child graphs, and the planner just needs to (a) push the container, then (b) call container.activateGraphForRoute(matched) — which is already what handleCrossGraphForward in Graph.kt:104 does for cross-graph navigation. No new code on the bestbuds side; it falls out of the planner using the same code path the JS web bridge uses today.

15.1 What BestBuds adds

  1. Manifest + Info.plist edits (above).
  2. Reaktor.DeepLink = ... assignment inside Reaktor.start { ... } on each platform.
  3. One attach(AuthGuard()) per protected feature graph.
  4. bestbuds/cloud/.well-known/{assetlinks.json,apple-app-site-association} served by the worker.
  5. An onboarding deep link intercept that opens the existing screen instead of /home.

16. Testing strategy

commonTest

  • DeepLink.parse — table-driven: custom scheme, https, with/without query/fragment, percent-encoding, trailing slash.
  • RouteIndexVisitor — fixture graph with nested containers; assert every leaf route is indexed exactly once and its parentChain is correct.
  • DeepLinkResolver.resolve — happy path, unknown host, unknown path, gated by stub guard, action wins over route.
  • BackStackPlannerVisitor — round-trip: plan → dispatch → assert backStack.entries matches expected pattern sequence.
  • urlFor round-trip: resolve(urlFor(route, payload)).Plan.last().payload == payload.

androidTest / iosTest

  • Instrument BestBudsActivity with a fake Intent, assert NavigationCapability.backStack top equals expected route.
  • iOS XCUITest: xcrun simctl openurl booted bestbuds://chats/abc + screenshot assertion on the chat screen header.

e2e via Maestro (already wired in bestbuds/maestro)

- launchApp:
    appId: ai.bestbuds.app
    deepLink: bestbuds://chats/abc123
- assertVisible: "Chat with ..."
- back
- assertVisible: "Chats"

17. Phased plan

Phase 1 — Common resolver · ~1 day

DeepLink, DeepLinkConfig, DeepLinkResolver, Resolution, RouteIndexVisitor. Unit tests on commonTest with a fixture graph. Deliverable: green tests; no platform integration yet.

Phase 2 — Web bridge refactor · ~½ day

Make WebNavigationBridge consume the new resolver. Zero behavioral change for the user; proves the new abstraction matches what we already shipped.

Phase 3 — Android · ~1 day

Manifest intent-filters, singleTask, onNewIntent wiring, AndroidDeepLinkBridge. adb shell am start -a android.intent.action.VIEW -d "bestbuds://chats/abc" opens the chat.

Phase 4 — iOS · ~1 day

Info.plist URL type, scheme-aware application(_:openURL:options:), DarwinDeepLinkBridge, simulator smoke test via simctl openurl.

Phase 5 — Guards & pending queue · ~1 day

DeepLinkGuard, GuardCollectorVisitor, PendingDeepLink with KeyValueStore persistence. AuthGuard in reaktor-auth. attach(AuthGuard()) on every BestBuds feature graph.

Phase 6 — Synthetic back stack · ~1 day

BackStackPlannerVisitor. End-to-end test: cold launch into /chats/abc; press back → /chats; press back → /home.

Phase 7 — Desktop + .well-known + AppLink autoVerify · ~1 day

Desktop args + setOpenURIHandler; Conveyor Info.plist edit for the .app. Generate assetlinks.json + apple-app-site-association from SchemaEmitterVisitor; serve via Cloudflare worker. Flip Android autoVerify="true".

Phase 8 — Actions, share URLs, analytics · ~½ day

DeepLinkAction registry, Graph.shareUrlFor(...), DeepLink.Source tags propagating into analytics events.

tip

Total: ~6 engineer-days end to end, including server-side .well-known deployment and Maestro coverage.

18. Risks & open questions

Risks

  • Existing iOS URL handler swallows everything. Today application(_:openURL:options:) sends every URL to GIDSignIn and returns its result. Switching to scheme-aware dispatch must be done carefully so OAuth still works. Mitigation: keep GIDSignIn.handleURL as the fallback branch and only intercept when scheme == "bestbuds".
  • Android singleTask side-effects. Changing launch mode may alter task-stack behavior for other entry points (notifications). Run the existing Maestro suite before merging.
  • Universal Link autoVerify race. Until the worker serves valid assetlinks.json with the right cert fingerprint, autoVerify will silently fail and links keep going to the browser. Ship Phase 3 with autoVerify="false" (chooser dialog) and flip in Phase 7 once verified via adb shell pm verify-app-links --re-verify ai.bestbuds.app.
  • Guard collection vs. capability scoping. A DeepLinkGuard on a parent Graph should apply to all descendants but not leak across ContainerNode peer graphs. The visitor walks the parent chain of the matched node, not all neighbors, so this is correct — but worth covering with a fixture test in Phase 5.
  • Process-death persistence of pending links. Persisting to KeyValueStore means a user who closes the app mid-auth and reopens an hour later still resolves the link. Add a TTL (default 15 min) and source tagging so we don't accidentally re-fire a stale push.

Open questions

  • Should the URI surface live at bestbuds.app or a dedicated l.bestbuds.app? The latter avoids collision with the marketing site already served from bestbuds.app via the existing worker.
  • Should bestbuds:// remain after Universal Links are verified? Custom schemes are still useful for desktop and dev tooling; recommendation is keep both.
  • Should DeepLinkAction live in reaktor-graph or stay app-side? Most actions touch app-specific repositories. Recommendation: keep the registry generic, register handlers from the app.
  • Bot/messaging server already produces share strings (Telegram/SMS). Should it call shareUrlFor via a new endpoint, or import the schema JSON at build time? Cheaper to ship the JSON.

Source files referenced: reaktor/reaktor-graph/src/commonMain/.../visitor/Visitor.kt, core/node/RouteNode.kt, navigation/NavigationCapability.kt, navigation/NavCommand.kt, navigation/Payload.kt, core/Graph.kt, core/node/ContainerNode.kt, reaktor-io/.../network/RoutePattern.kt, reaktor-graph/src/jsMain/.../navigation/WebNavigationBridge.kt; BestBuds: modules/app/src/commonMain/.../BestBuds.kt, targets/appAndroid/src/main/AndroidManifest.xml, targets/appDarwin/iosApp/Info.plist, modules/app/src/iosMain/.../BestBudsApplication.kt, targets/reaktorDesktop/src/main/kotlin/Main.kt.

Related docs

  • Graph Visitors - Use before designing a new graph pass or selector.
  • Graph Migration - Use when evolving reaktor-graph without breaking product apps.
  • Auth Architecture - Use before changing reaktor-auth or request authorization boundaries.
  • UI & Design System - Use before touching reaktor-ui tokens/components/theming, designing the tactile runtime (interaction/physics/field/skins/host-tree/reconciler), or planning adaptive layouts and AI/server-driven UI.
  • Perf and Observability - Use before optimizing or adding instrumentation.
  • Server - Use before adding any production backend primitive (resilience, messaging, data patterns) or designing the Spring/Workers server runtime.

On this page

  • 1. Executive summary
  • 2. Current state
  • 2.1 What works
  • 2.2 What's missing
  • 3. Goals & non-goals
  • 4. URI model
  • 5. Architecture
  • 6. The four visitors
  • 6.1 RouteIndexVisitor
  • 6.2 BackStackPlannerVisitor
  • 6.3 GuardCollectorVisitor
  • 6.4 SchemaEmitterVisitor
  • 7. Common API surface
  • 8. Android bridge
  • 8.1 Manifest
  • 8.2 Activity wiring
  • 9. iOS bridge
  • 9.1 Info.plist
  • 9.2 AppDelegate wiring
  • 10. Web bridge
  • 11. Desktop bridge
  • 12. Auth guards & pending links
  • 12.1 The flow
  • 12.2 PendingDeepLink
  • 12.3 Declaring a guard
  • 13. Actions — non-route deep links
  • 14. .well-known association files
  • 14.1 Android — assetlinks.json
  • 14.2 iOS — apple-app-site-association
  • 15. BestBuds wiring
  • 15.1 What BestBuds adds
  • 16. Testing strategy
  • 17. Phased plan
  • 18. Risks & open questions
  • Related docs

Start here

  • Overview
  • Reaktor Essays
  • Framework Overview
  • Vision and roadmap
  • Contributing

Using the framework

  • Core
  • Service Layer
  • Auth Architecture
  • Data Layer

The graph model

  • Graph Kernel
  • Actor Model
  • Unified Graph
  • Formal Methods

Workbench and agents

  • Control Plane
  • Cloud Control Plane
  • Conductor
  • Reaktor Build

Reaktor — built 2026.