---
title: Deep Links (omniscio:// URLs)
---

# Deep Links (omniscio:// URLs)

## What it is

Omniscio registers itself with your OS as the handler for its custom link protocol, so a deep link — clicked from an email, pasted into a terminal, written in a Slack message, rendered inside the app itself — opens Omniscio and navigates to the exact view you specified. **Two schemes, one behavior:** links the app now _builds_ (Copy-link buttons, agent-emitted links, dashboard/email links) carry the **brand `omniscio://` scheme** (instance-suffixed to `omniscio-<id>://` on a dev/sandbox instance so the link stays routable while that instance runs); the older **`agentmc://` scheme is still accepted on the way in**, so an existing `agentmc://…` link a user saved keeps routing. The parser accepts either base — so both `omniscio://session/<id>` and `agentmc://session/<id>` land on the same view.

The routes are: **inbox**, **a specific inbox card** (`omniscio://inbox/item/<id>` — opens the inbox on that exact card, whether it is a pending approval or an alert; for an approval, resolving it returns you to the session that requested it), **a specific inbox alert** (`omniscio://alert/<id>` — the canonical form of the alert case, and the link `POST /alert` returns to the agent that raised the card, so an agent never has to guess; unlike an approval there is no return-to-session step when you deal with it), **a specific project** (fuzzy-matched by name), **new session in a project** (optionally with the first message pre-filled), **a specific session by id** (optionally scrolled to a message), **a specific KMS note** (by vault-relative path), **a mind map** (by id), **a specific setting** (by id, section, or fuzzy phrase — opens Settings and scrolls to + flashes the row), **a Super Prompt** (`omniscio://superprompt/<id>` — opens the Super Prompts picker on that prompt), **an Arij route** (`omniscio://arij/<path...>` — opens the Arij area on that route), three Ollert routes — **open an invitation** (`omniscio://trello/invite/<token>`), **jump to a board** (`omniscio://trello/board/<boardId>`), and **jump to a card** (`omniscio://trello/card/<boardId>/<cardId>`), two Mission Control routes — **join a shared board/workspace/dashboard** (`omniscio://monday/join/<kind>/<token>`) and **complete an invitation** (`omniscio://monday/invite/<token>`), a **team chat message** link (`omniscio://chat/<kind>/<wsId>/<channelId>?message=<msgId>`), a **Supermail email thread** (`omniscio://supermail/thread/<threadId>` — opens that email in the Supermail panel), and — on any navigate-only route — an optional **`?highlight=<anchor>`** query that spotlights a specific `[data-ui-anchor]` control after the view mounts (e.g. `omniscio://setting/cli-control?highlight=cli-control-regenerate` navigates to the CLI Control settings page and rings the Regenerate button). Deep links are the cheap, scriptable way to wire Omniscio into anything — AutoHotkey hotkeys, cron jobs, Obsidian notes, bookmarks, email signatures — without a token or HTTP call. The setting route is also how an Omniscio agent hands you a one-click jump to the exact toggle it's talking about: it writes `omniscio://setting/<id>` in chat and clicking it takes you straight there (it only _navigates_ — you still flip the switch yourself).

## Where to find it

There is nothing in the interface to open — deep links are a convention, not a screen. You use one
by putting it wherever a URL can go: a bookmark, an AutoHotkey hotkey, a note, an email, or a
terminal. Inside the app, the places that hand you one (a message's share icon, an approval card,
a setting an agent mentions in chat) do so as a clickable link, so the only thing you have to
supply yourself is the address.

## How it behaves

### How to use it

1. **Open the inbox.** `omniscio://inbox` — no arguments. Good for a taskbar shortcut or a "catch up" bookmark. (`agentmc://inbox` still works too.) **Jump straight to one card:** `omniscio://inbox/item/<id>` opens the inbox and lands on that specific card — a pending approval **or** an alert (`<id>` is the card's row id). It is the generic spelling; `omniscio://alert/<id>` is the canonical form of the alert case and the one `POST /alert` hands out. A link to a card that is no longer in the inbox says so and opens nothing, rather than leaving you somewhere else on the list. This is the link the **CLI approval gate hands the agent that queued the approval** (it now rides in the `202` create response), so an agent can give you a one-click "approve this" link. Arriving via the link is special: when you approve **or** reject the approval, Omniscio returns you to the **session that requested it** — whereas resolving the same approval the normal way in the inbox just advances to the next approval (no session jump). It's navigate-only (no spawn/send), so it isn't approval-gated itself. A malformed or already-resolved id lands on the inbox with a graceful empty pane rather than erroring. See [settings-patch-approval.md](settings-patch-approval.md) and [approvals-hub.md](approvals-hub.md).
2. **Jump to a project.** `omniscio://project/<ref>` — `<ref>` is a display name, the project's **id**, or its sentinel `folderPath` (`__cronjobs__`); all three are exactly what `GET /projects` prints for it. Matching is tiered, and every EXACT tier outranks every fuzzy one: **exact name** → **id** → **sentinel path** → **starts-with** → **unique contains** → **lone accent-fold**. So `omniscio://project/api` picks `Api-Gateway` if that is your only project containing `api`, while an id can never lose to a prefix hit on some other project. **Every project the app lists is reachable**, virtual panels (Settings, Cron Jobs, Inbox Pilot, …) included — this link only *navigates*, and the app can navigate to all of them. The narrower **spawnable-only** filter still applies to `…/new` below (you cannot start a session inside `__settings__`) and to `POST /project/<name>/new`; before 2026-09-09 that spawn filter was wrongly applied to plain navigation too, which made `GET /deep-link/resolve` report real projects — and every project id — as broken links.
3. **Start a new session with a prompt.** `omniscio://project/<name>/new?prompt=<url-encoded prompt>`. Omniscio opens the project and launches a new session with the prompt pre-populated, ready to send. The prompt parameter is optional — without it you just get an empty new session. **A deep link always asks first:** the request lands in your inbox as an approval row ("New session from AI") showing the project and a preview of the prompt, and the session starts when you approve it — nothing is spawned before that. This is deliberate, and it is the same policy the Super Prompt link above follows (an external link _lands_ on the picker; it never auto-runs). A URL is not a gesture you made inside the app: the scheme is registered with your OS, so a page or an email can ask you to open one, and the prompt in it is written by whoever built the link. Approving is what makes it yours. Once approved the spawn is a background one — the sidebar gets a new row and your current view doesn't change — and a bottom-left toast announces it: the first few words of your prompt right away, then the session's AI-generated title once that lands (about 3 seconds), so you can tell at a glance which session just started; click its **Open** button to jump to it. For AI-initiated spawns that come from somewhere _other_ than a link — an authenticated `POST /project/<name>/new` from the CLI, a cron job, another session — the gate is the **Settings → CLI Control → "Require approval for AI session spawns"** toggle, which is off by default; turn it on and those land in the same inbox row ("New session from AI"), the same way cron / automation / agent-driven-spawn approvals work.
4. **Link directly to a session.** `omniscio://session/<id>` — where `<id>` is the session's UUID (optionally `?message=<msgId>` to scroll to a specific message). Used internally by markdown rendering (the search palette and agent messages render this format as a clickable link) and externally for any place you want to hand someone a stable pointer to one conversation. Added in commit `4d929b3ec` to unblock linking from search results.
5. **Open a specific KMS note.** `omniscio://note/<vault-relative-path>` (optionally `?vault=<vaultId>`). Omniscio activates the **KMS** panel and opens that note as a tab — e.g. `omniscio://note/Architecture.md` or `omniscio://note/One-on-Ones/Brett.md` (sub-folders work as real `/` separators or a single `%2F`-encoded segment; spaces percent-encode as `%20`). It's non-destructive (no spawn, no send), so it isn't approval-gated. A **cold** click (KMS not open yet) still lands — the request is held until the vault finishes loading. Requires KMS to be enabled (Settings → Features → **Enable KMS**); a disabled KMS, or a path that no longer matches an indexed note, surfaces a toast instead of opening a blank tab.
6. **Open a mind map.** `omniscio://mindmap/<id>` — opens the Mind Map panel and loads the map with the given id. The id is the same one returned by the mind-map library or the CLI create/import routes; the link is percent-encoded and decoded by the parser. Requires the Mind Map feature to be on (Settings → Features → **Mind Map**).
7. **Open a setting.** `omniscio://setting/<id>` — opens **Settings**, navigates to the right section, then scrolls to and briefly flashes the matching row. `<id>` can be **any of four things**, because different callers hold different names for the same setting: an exact setting id (`preload-sessions`, `theme`, `font-size`); a whole-section id (`appearance`, `cli-control` — opens that page with no specific row); the setting's **AppSettings key** in camelCase (`gitGuardrailsReadyTagGateEnabled`, `sharesOpenInApp`) — the same key `PATCH /settings/:key` and `GET /settings` speak, and the only name for a setting an AI is ever handed; or a fuzzy phrase (`dark mode`, `sidebar position`) resolved through the **same** catalog + search engine the Settings search box uses, so it lands exactly where typing the phrase would. A confident hit (the phrase is a setting's label, or the only result) scrolls to + flashes that row; an ambiguous phrase opens the best-guess section page **without** flashing a row, so a loose guess never highlights the wrong setting. A key that doesn't exist is split at its camelCase humps and retried as words, so you get a "did you mean…?" naming the nearest real settings rather than a dead end. A query that matches nothing at all opens Settings (last-viewed section) and shows a toast — never a blank or wrong landing. It's non-destructive (it only navigates — it never _changes_ a setting), so it isn't approval-gated. This is the route an Omniscio agent uses to point you at a toggle it mentioned in chat.
8. **Open a Super Prompt.** `omniscio://superprompt/<id>` — opens the Super Prompts picker on that specific prompt (from a Copy-link button or an agent-emitted link). Following an external link only _lands_ on the picker (confirmation-gated) — it never auto-runs the prompt; "Run it now" stays a separate in-app gesture.
9. **Open an Arij route.** `omniscio://arij/<path...>` (optionally `?jql=…`) — opens the Arij area on a specific route, e.g. `omniscio://arij/browse/ARIJ-123` or `omniscio://arij/projects/ARIJ/board`. The whole remainder (path + query) is handed opaque to Arij's own router, which resolves it.
10. **Ollert links.** Three sub-routes under the `trello` host activate the Ollert panel and navigate its in-memory router:
    - `omniscio://trello/invite/<token>` — open an invitation (used by the "Open in Omniscio" button on the `amcback.jls.dev/invite/<token>` landing page)
    - `omniscio://trello/board/<boardId>` — jump directly to a board
    - `omniscio://trello/card/<boardId>/<cardId>` — jump directly to a card (the card modal over its board). The `boardId` and `cardId` are percent-encoded in the URL and decoded by the parser.
11. **Mission Control links.** Three sub-routes under the `mission-control` host (the older `monday` host is still accepted):
    - `omniscio://mission-control/join/<kind>/<token>` — accept a shared board / workspace / dashboard (`<kind>` is `board`, `workspace`, or `dashboard`)
    - `omniscio://mission-control/invite/<token>` — complete a member invitation. Tokens are percent-encoded and decoded by the parser.
    - `omniscio://mission-control/board/<boardId>[?item=<itemId>]` — open a Mission Control board directly. Activates the MC panel, navigates its internal router to `/board/<boardId>`. The optional `?item=` query param focuses a specific item/card on the board.
12. **Link to a team chat message.** `omniscio://chat/<kind>/<workspaceId>/<channelId>?message=<messageId>` — where `<kind>` is `org` (company workspace) or `workspace` (self-serve workspace). Omniscio activates Team Chat, switches to that workspace and channel, and navigates to the message. The `?message=` anchor is optional — omitting it produces a channel-level link. Every message's hover toolbar has a **share icon** (and the mobile long-press action sheet has a **"Copy link"** row) that copies this link to your clipboard, Discord-style.
13. **Open a Supermail email thread.** `omniscio://supermail/thread/<threadId>` — activates the **Supermail** panel, forces its Mail view, and opens that email thread (`<threadId>` is Supermail's thread id, percent-decoded by the parser). It reuses the exact open path a Supermail notification click and the "Find Email" quick-launch already use, so a cold click still lands once the panel hydrates. It's non-destructive (reads, never sends), so it isn't approval-gated. Note the sibling `omniscio://supermail/auth` link is **not** this route — that's the OAuth sign-in callback, handled in the main process and never routed as a navigation.
14. **Spotlight a control after navigating.** Append `?highlight=<anchor>[&highlightTitle=<title>&highlightMessage=<message>]` to any navigate-only deep link — inbox, inbox alert, project, session, note, setting, mind-map, Arij, Ollert, Mission Control, team-chat, or Supermail thread. After the target view mounts, Omniscio spotlights the element carrying `data-ui-anchor="<anchor>"` using the same glass-tooltip overlay as `POST /ui/highlight`. Example: `omniscio://setting/cli-control?highlight=cli-control-regenerate` opens CLI Control settings and rings the Regenerate button. Notes and constraints:
    - **Navigate-only** — the `?highlight=` query is silently ignored on spawn (`/new`) and any other non-navigation path, because those paths don't land on a stable renderable view. Use `session/<id>?message=<msgId>` (no `?highlight=`) to scroll to a specific chat message; a message inside the conversation scroller can't be spotlighted (the scroll engines would conflict).
    - **Gated** — the feature is off by default (`deep-target-highlight` unreleased feature flag). A click on the link navigates normally; the spotlight simply doesn't appear when the gate is off.
    - **Anchor charset** — `<anchor>` must be lowercase alphanumeric + hyphens only (the same names listed in `GET /ui/snapshot`). An invalid charset is ignored rather than erroring, matching the principle of graceful degradation for shareable links.
    - **Same agent-nav guard** — if the link is emitted by an agent session, the click-to-go toast shows the spotlight spec and replays it when the user clicks through, so the highlight still fires after a human-controlled navigation. The agent never forces the view change.
15. **Wire it up anywhere.** On Windows, any shortcut pointing at a URL that starts with `omniscio://` (or the still-accepted `agentmc://`) works. On macOS, Chrome/Safari will prompt the first time you click a link then remember. AutoHotkey: `Run omniscio://inbox`. PowerShell: `Start-Process omniscio://inbox`.

### When an agent writes a broken link

Omniscio checks the `omniscio://` links an agent hands you, and when one provably points at nothing
it fixes it **in place, without involving you**.

What you see: nothing. What happens underneath is a short exchange you are never shown. Omniscio
tells that agent its link will not open and asks for a machine-readable correction — one marker line
(`[[OMNISCIO_LINK_FIX]]`) followed by `dead-link -> working-link`, one per link. When the answer
comes back, Omniscio verifies each replacement actually resolves, rewrites the link **inside the
original message**, and pushes the corrected text straight to whatever you are looking at, phone
included. The request and the answer are both hidden from the thread, and your session goes back to
the state it was in, so no new card lands in your inbox. Your original message just becomes right.

Before this, the agent was asked to "send the corrected link", so it wrote you a **whole second
message** while the first one still carried the dead link — you had to read both and mentally
discard one.

Three things it deliberately will not do. It never swaps in a replacement that is itself dead (the
new link is existence-checked first; a link it cannot check — say the app is running headless — is
allowed through, matching how every other deep-link check treats an undecidable answer). It never
hides a real message: the correction is recognised only when the agent's entire visible reply IS the
payload, so an agent that has **no** working replacement just answers you in plain English and you
read that normally. And if it turns out no message actually contains the link the agent named, it
un-hides the reply rather than leaving you with nothing — a silent failure would be worse than a
visible one.

Invariants: [deep-link-existence-contract.md](/.claude/memory/contracts/deep-link-existence-contract.md).

### On mobile (the web-access app)

Deep links work on the phone too — with one unavoidable twist: a phone browser can't open a raw `omniscio://` link (no mobile OS registers a desktop custom scheme, and a web app can't claim one), so on mobile the same links route two ways.

**Links _inside_ the app** (the ones an agent writes in chat, search results, alerts, Copy-link buttons) are rendered by `CopyableLink` as click handlers, not raw `<a href="omniscio://…">`. Session / setting / mind-map / draft / share links already route through pure store navigation, so they worked on mobile from the start. Every _other_ route (project, inbox, note, chat, page, Ollert, Mission Control, Supermail thread, Arij) used to round-trip through `openAppLink` → `DEEP_LINK_OPEN`, which is in the web-access WS `BLOCKED_CHANNELS` (it emits a `DEEP_LINK_ACTION` push that drives the **desktop** window, and that push is not forwarded to web clients) — so on a paired phone the tap did nothing. Now `openAppLink` is platform-aware: on the web (`isBrowser`) it calls the new **read-only `DEEP_LINK_RESOLVE`** invoke (parse-only via `parseDeepLinkUrl` — no push, no navigation, no entity lookup, so it is safe over the WS bridge and is baselined + `resolve`-adjudicated), then dispatches the returned action through the **same** `routeDeepLinkAction` the desktop push feeds, via a module-level handle `useDeepLinkListener` registers on mount ([deep-link-dispatch-handle.ts](/src/renderer/src/lib/deep-link-dispatch-handle.ts)). The desktop path is unchanged. See [/src/renderer/src/lib/open-app-link.ts](/src/renderer/src/lib/open-app-link.ts).

**Links tapped from _outside_ the app** (an email, a text, a bookmark, a web-push notification) must be an **HTTPS** URL on your web-access origin carrying the target: `https://<your-web-host>/#to=<url-encoded omniscio:// link>`. The target rides in the URL **fragment** (`#to=`) — like the `#auto=` pairing token, the fragment is never sent to the server and never lands in an access log; a `?to=` query is accepted as a redirect-safe fallback. On boot the renderer read-and-clears that target ([boot-deep-link.ts](/src/renderer/src/lib/boot-deep-link.ts)) and routes it through the same `DEEP_LINK_RESOLVE` + `routeDeepLinkAction` path an in-app tap uses — the mobile analog of the desktop cold-start argv pull. If the phone isn't signed in yet (stale cookie or a fresh device), the login page carries the `#to=` fragment across sign-in (same-origin only; the token stays in `#auto=`) so it still lands. This also **fixes web-push notification cold-taps**: the legacy `/?session=<id>` push payload was ignored at boot and now routes. Build the HTTPS form with `buildMobileDeepLink(origin, appLinkUrl)` ([build-mobile-deep-link.ts](/src/renderer/src/lib/build-mobile-deep-link.ts)) — no token is embedded, so the link is a safe "open this on my phone" pointer, not a capability URL.

**Safety invariants:** a boot / login target is _always_ re-parsed through the canonical `parseDeepLinkUrl` and only ever becomes an internal `DeepLinkAction` — a non-app-link target (e.g. `#to=https://evil.com`) is refused, so there is **no open redirect** and no raw-URL navigation; and no secret ever rides in a deep link (the pairing token stays in `#auto=`, the `#to=` target is a non-secret route id). Follow-up (not yet built): a one-click **"Copy mobile link"** affordance that surfaces `buildMobileDeepLink` in the UI. Not addressed: literal `omniscio://` links tapped outside the app on a phone (that needs a native/installed app to claim the scheme).

## For agents

### How it works

**A link reaches the running app even when its data folder was moved.** Only one copy of Omniscio runs per data folder, and a copy that runs from a moved folder (`DATA_DIR`, or `--user-data-dir`) registers its link handler with that folder built into the command. So a link opened from a program that doesn't carry the folder setting (an agent's shell, for one) still hands itself to the running app instead of starting a second copy on the default folder (Sentry 7759760656). The command comes from `protocolClientLaunch()` in [/src/main/app/single-instance-policy.ts](/src/main/app/single-instance-policy.ts); a default-folder install and a named test/sandbox copy register as before, and the running app re-registers at every start. Invariant: `a-link-launch-finds-the-running-app` in [single-instance-liveness-contract.md](/.claude/memory/contracts/single-instance-liveness-contract.md).

**…and the hand-off brings its window to the front.** The running app skips raising its window for a second launch that looks like a test or development run (a runner raise once froze it for 27 s), and it used to judge that by folder names alone — so where the app's own program files sit in the shared modules folder its test runs also use, every link hand-off looked like a runner: the app moved to the right place but stayed hidden behind other windows. The hand-off now carries the running app's own folder (the value `ownLaunchEntry()` in [/src/main/app/single-instance-policy.ts](/src/main/app/single-instance-policy.ts) returns, which the link registration also writes), and a launch carrying exactly that folder is the app relaunching itself, so the window comes forward; a run launched from a worktree never carries it and is still never raised. The check lives in [/src/main/app/test-runner-invocation.ts](/src/main/app/test-runner-invocation.ts). Invariant: `a-link-hand-off-raises-the-running-app` in [single-instance-liveness-contract.md](/.claude/memory/contracts/single-instance-liveness-contract.md).

Protocol registration is done at app startup via Electron's `app.setAsDefaultProtocolClient(...)` — the app registers **both** the primary `agentmc` base (also the OAuth / Info.plist scheme) and the brand `omniscio` alias (each instance-suffixed on dev/sandbox). Outbound links the app builds carry the brand `omniscio://` scheme (`getInstanceProtocol()` / `computeBrandProtocolScheme` in [/src/main/services/deep-link.ts](/src/main/services/deep-link.ts)); the parser accepts either base (`getAcceptedInstanceProtocols()`), so an old `agentmc://…` link still routes. Inbound URLs arrive two ways: on macOS via the `open-url` event, on Windows/Linux via `process.argv` on a second-instance launch — both paths funnel into `handleDeepLinkArgs()`, which parses the URL with [/src/main/services/deep-link.ts](/src/main/services/deep-link.ts). Parsing is now **forgiving**: `parseDeepLinkUrl()` validates the scheme, then accepts the natural variants before extracting the route — plural hosts (`settings`→`setting`, `notes`→`note`, `chats`→`chat`, …), the `?q=` query form for `setting` (matching the HTTP `GET /settings/open?q=` grammar), and a case-insensitive chat kind — and returns a typed `DeepLinkAction`. Every alias is **additive** — it only turns a former no-match into a match, never re-points an already-working link (locked by [/tests/unit/deep-link-forgiveness.test.ts](/tests/unit/deep-link-forgiveness.test.ts), which asserts each plural resolves identically to its singular). A URL that STILL doesn't parse is no longer a silent no-op: `dispatchDeepLink` shows a "couldn't open that link" toast instead of just focusing the window. To dry-run a link WITHOUT firing it, the CLI route `GET /deep-link/resolve?url=<omniscio://…>` returns the resolved action or a self-correcting "did you mean". Project refs resolve through the ONE shared matcher `findProjectByRef()` in [/src/shared/project-ref-match.ts](/src/shared/project-ref-match.ts) (exact name / id / sentinel path, then the fuzzy tiers); admissibility is the CALLER's: `findActivatableProject()` passes the whole project list because navigation reaches every project, while `findProjectByName()` pre-filters to spawnable projects for the paid spawn path. Main and the renderer share that one implementation — they used to be hand-kept copies that silently diverged on exactly this filter. For `new-session` actions, the main process consults the chokepoint `routeAiSessionSpawnRequest()` in [/src/main/services/ai/ai-session-spawn-router.ts](/src/main/services/ai/ai-session-spawn-router.ts) **before** emitting any renderer push: if `requireApprovalForAiSessionSpawn` is off (the default), it returns `{ kind: 'immediate' }` and the main process emits `DEEP_LINK_ACTION` — **no window-foreground steal** (changed 2026-05-19, `fix/spawn-no-focus-steal`); the renderer's deep-link router then runs the spawn in the background (`launchSession({ background: true })`) and the user-visible affordance is a bottom-left "New session in <project>" toast with an "Open" action button (fired by `handleSessionLaunched` in [/src/renderer/src/hooks/useSessionSync.ts](/src/renderer/src/hooks/useSessionSync.ts) off the `SESSION_LAUNCHED` push — it shows the prompt's opening words immediately, then swaps in the AI-generated title via `updateToast` if that lands within ~3 s; the older `announceSpawn` renderer-router path is superseded), so a drive-by `agentmc://` link can never yank focus away from whatever the user is doing. **In-app navigation is also suppressed** (added 2026-05-21, `fix/ai-spawned-session-no-focus`): `launchSession({ background: true })` threads a `background: true` field through the SESSION_LAUNCH IPC payload, the main process re-emits it on the resulting `SESSION_LAUNCHED` push, and [/src/renderer/src/hooks/useSessionSync.ts](/src/renderer/src/hooks/useSessionSync.ts)'s `handleSessionLaunched` skips the `setActiveSession` + project-switch writes when `payload.background === true`. The session still appears as a new sidebar row; whatever session you were reading stays on screen. Only the toast's "Open" button moves your view. If `requireApprovalForAiSessionSpawn` is on, the router instead inserts a `session.spawn.deep_link` row into `cli_pending_actions` (kindCap = 5 burst limit), emits `CLI_PENDING_CHANGED`, and the user approves/rejects inline via `DeepLinkSpawnApprovalPane` in the inbox — the same `cli-pending-dispatcher` that drives every other CLI-pending approval then emits the deferred push on approve. The renderer side lives in [/src/renderer/src/hooks/useDeepLinkListener.ts](/src/renderer/src/hooks/useDeepLinkListener.ts) and [/src/renderer/src/lib/deep-link-router.ts](/src/renderer/src/lib/deep-link-router.ts), which translates the action into store mutations (activate project, launch session with prompt via `launchSession({ background: true })`, setActiveSession). Markdown-rendered `agentmc://session/<id>` URLs are intercepted by [/src/renderer/src/components/ui/agent-markdown-path-utils.ts](/src/renderer/src/components/ui/agent-markdown-path-utils.ts) (`parseAgentmcSessionUrl()`) so they route internally instead of firing `shell.openExternal`. The fuzzy-match edge case — custom protocols don't auto-lowercase hostnames — is handled by manual normalization in the parser. The **`note`** route (`agentmc://note/<relative-path>`) parses to an `open-note` action; because it isn't the paid `new-session` path, the main process forwards it straight over `DEEP_LINK_ACTION`, and the renderer router activates the KMS virtual project (`KMS_PROJECT_ID`) then calls the KMS store's `requestOpenNote`, which stashes the path in `pendingOpenNotePath` and opens it from `loadTree`'s (epoch-guarded) tail once the vault hydrates — so a cold link lands on the right note even before the panel has mounted. KMS-disabled (virtual project absent) and renamed/deleted notes (no match in the indexed tree) each surface a toast rather than a blank tab. The **`setting`** route (`agentmc://setting/<query>`) parses to an `open-setting` action carrying the raw `query` — the main process can't resolve it because the settings catalog lives in the renderer, so it forwards the query straight over `DEEP_LINK_ACTION` and the renderer router's `open-setting` case hands it to `openSettingFromLink()` in [/src/renderer/src/lib/open-setting-from-link.ts](/src/renderer/src/lib/open-setting-from-link.ts). That glue resolves the query with the pure `resolveSettingLinkTarget()` in [/src/renderer/src/lib/settings-link-target.ts](/src/renderer/src/lib/settings-link-target.ts) — the **same** `SETTINGS_SEARCH_INDEX` + `searchSettings()` the Settings search box uses — then stages the resolved `settingId` via `setPendingSettingId` and calls `navigateToSettingsProject({ section })`, exactly mirroring the right-click "Open in Settings" affordance; `useScrollToSetting` then polls the DOM for `[data-setting-id="…"]` and scrolls + flashes it. It resolves in a fixed cascade — **exact entry id → section id → exact `settingsKey` → fuzzy** — and that order is load-bearing: the `settingsKey` tier deliberately sits BELOW the section tier so an all-lowercase key equal to a section id can never silently re-point a link that already worked. The fuzzy tier retries once with camelCase humps split into words, because a key folds to one long token that no tier of the search engine can match; that retry is also what produces the "did you mean" candidates (`settingLinkCandidates`, the single source both the user's toast and the CLI's `didYouMean` read from). The resolved `settingId` is **always** a known catalog id (never the raw link text), so the downstream `querySelector` is injection-safe; a fuzzy hit only scrolls when it's confident (label-exact or single result), and a no-match opens Settings + toasts. `GET /deep-link/resolve` reaches this same resolver through the `window.__amcResolveSettingLink` renderer bridge (installed beside `__amcSearchSettings`), so the endpoint's verdict and what a click actually does can never diverge — main cannot import the settings catalog, which is why it goes via the bridge. Invariants: [setting-deep-link-resolution-contract.md](/.claude/memory/contracts/setting-deep-link-resolution-contract.md). The in-chat path is the twin: `parseAgentmcSettingUrl()` in [/src/renderer/src/components/ui/agent-markdown-path-utils.ts](/src/renderer/src/components/ui/agent-markdown-path-utils.ts) detects the URL in a rendered agent message and the `CopyableLink` handler calls the same `openSettingFromLink()`. Both call sites import the resolver + glue **lazily** so the settings catalog stays out of the chat-markdown and main-listener bundles until a setting link is actually followed. The **`supermail`** route (`omniscio://supermail/thread/<id>`) parses to an `open-supermail-thread` action; like note/setting it isn't the paid spawn path, so the main process forwards it over `DEEP_LINK_ACTION` and the renderer router (`handleOpenSupermailThread`) hands the thread id to `openSupermailDeepLink('/thread/<id>')` — the same activate-panel → force-Mail-tab → navigate flow a Supermail notification click uses (so it self-activates; the router does no redundant project switch). The one subtlety is the **auth-vs-thread split**: `omniscio://supermail/auth` (the OAuth JWT callback, which carries the token in its fragment) is intercepted BEFORE the generic parser at BOTH entry points — the `single-instance.ts` reopen handler and the `index.ts` cold-start argv scan — via the shared `isSupermailAuthDeepLink()` predicate in [/src/main/ipc/supermail-deeplink-handler.ts](/src/main/ipc/supermail-deeplink-handler.ts) and handled in main, while every OTHER supermail path falls THROUGH to the generic parser; and `parseDeepLinkUrl()` deliberately returns null for `supermail/auth` (only `thread` is a generic route) so the callback is never double-handled. A future supermail deep-link path must extend `isSupermailAuthDeepLink` only if it needs the main-process credential handling — otherwise it's a plain generic route. Tests in [/tests/unit/services/ai-session-spawn-router.test.ts](/tests/unit/services/ai-session-spawn-router.test.ts), [/tests/unit/deep-link.test.ts](/tests/unit/deep-link.test.ts), [/tests/unit/deep-link-open-note.test.ts](/tests/unit/deep-link-open-note.test.ts), [/tests/unit/deep-link-open-superprompt.test.ts](/tests/unit/deep-link-open-superprompt.test.ts), [/tests/unit/lib/deep-link-router.test.ts](/tests/unit/lib/deep-link-router.test.ts), [/tests/unit/lib/deep-link-router-open-note.test.ts](/tests/unit/lib/deep-link-router-open-note.test.ts), [/tests/unit/lib/deep-link-router-open-setting.test.ts](/tests/unit/lib/deep-link-router-open-setting.test.ts), [/tests/unit/lib/settings-link-target.test.ts](/tests/unit/lib/settings-link-target.test.ts), [/tests/unit/lib/open-setting-from-link.test.ts](/tests/unit/lib/open-setting-from-link.test.ts), [/tests/unit/features/kms/request-open-note.test.ts](/tests/unit/features/kms/request-open-note.test.ts), [/tests/unit/lib/deep-link-router-open-supermail-thread.test.ts](/tests/unit/lib/deep-link-router-open-supermail-thread.test.ts), [/tests/unit/main/supermail-deeplink-handler.test.ts](/tests/unit/main/supermail-deeplink-handler.test.ts), and [/tests/unit/deep-link-renderer.test.ts](/tests/unit/deep-link-renderer.test.ts) cover every route + the OFF/ON branches + fuzzy tier + virtual-project filter + the note cold/warm/switch/not-found paths + the setting id/section/fuzzy/miss paths + the supermail thread route and the auth-vs-thread interception split. The **`inbox/item/<id>`** route (`open-inbox-item`) parses like the other inbox host paths; in the renderer router (`handleOpenInboxItem`) it navigates to the inbox, triggers a pending-approvals load (so a cold-start link has the row), opens that approval's pane via `setActiveApproval({ kind: 'cli-pending', id })`, and records a **return-intent** in [/src/renderer/src/lib/approval-return-intent.ts](/src/renderer/src/lib/approval-return-intent.ts). When that approval is later resolved, `resolveInboxAction` reads the intent (capture-before-await) and returns the user to the approval's `source_session_id` — suppressing the normal advance-to-next-approval — instead of advancing; it is gated so a plain inbox resolve is byte-identical (see the `approvals-are-one-inbox-group` sanctioned exception in the inbox-navigation contract). The link itself is emitted by the CLI approval gate via `inboxItemDeepLink(row.id)` in the queued `202` create-response body, so every gated agent approval carries it. **Both inbox hosts are resolved by KIND before the router sees them**, in `resolveInboxCardAction` in [/src/renderer/src/hooks/useDeepLinkListener.ts](/src/renderer/src/hooks/useDeepLinkListener.ts) — the wiring seam, NOT the router, because the router is deliberately store-free (`inbox-approval-deep-link-contract` → `no-router-side-validation`) and the `loadInbox*` deps it receives are typed `() => void` and wired as `() => { void store.load() }`, so a block-bodied arrow returns `undefined` and an await placed on them would settle immediately rather than after the load. The seam awaits the two REAL store promises, then: an open pending approval passes through untouched (today's path, byte-for-byte, so the return-intent still applies); an id that is an in-inbox alert is rewritten to the shipped `open-alert` action; and an id in neither list **routes nothing at all** and toasts. Routing nothing is the load-bearing half — filing the phantom `{kind:'cli-pending'}` selection is what the approval pane auto-closes on, and the cleared selection lets the idle auto-select take another row, which is exactly how a link to a card that was not there moved the reader somewhere else on the list. A load that FAILED, threw, or was superseded is a **doubt** and passes the action through unchanged rather than concluding the card is gone — and "failed" has to be read precisely here: both loads report whether they actually applied a fresh list, because a failed read comes back as a resolved `{ success: false }` envelope rather than a throw, and each store leaves its previous (on a cold start, empty) slice in place. Trusting the await alone would read that empty list as authoritative and tell the reader a live card had been dismissed. The resolution then grades its own knowledge rather than putting everything short of certainty in one bucket, because an all-or-nothing doubt is itself a defect: it discards evidence the resolver already holds. **One list being fresh and containing the id is proof on its own**, checked BEFORE any doubt — so a live alert still opens when the pending read failed, instead of falling back to the approval path (which files the selection with no existence check, auto-closes, and lets the inbox auto-select move the reader to a different row). **Absence only means "gone" when BOTH reads settled fresh** and both miss it; anything less routes the original action untouched. And **snooze is an answer, not a doubt**: `isInboxItemSnoozed` is a synchronous read of a renderer-local store, and the pane the original action opens auto-closes on exactly that predicate, so a snoozed card is reported rather than routed — with BOTH kinds consulted, since the snooze record is keyed `kind:id` and a hit under either kind says what the card is as well as that it is hidden. Every other action still routes synchronously in the same tick. The `GET /deep-link/resolve` existence check gained matching `open-alert` and `open-inbox-item` cases in [/src/main/services/deep-link-validation/existence.ts](/src/main/services/deep-link-validation/existence.ts), both asked against the SAME two list calls the inbox opens from (`listPending({ status: 'pending' })` and `listActiveAlerts(db, { sourceKind: 'agent' })`) so the check and the thing it checks cannot disagree — before them both hosts fell to `default: UNKNOWN`, which that file's own header defines as "assume the link is fine", and a live dry-run answered `ok:true` for both a card that was archived and an id that was invented. Neither case carries a `didYouMean`: both links genuinely open, so a suggested "repair" would wake an agent to rewrite a link that already works. `POST /alert` attaches the link only when the just-written card is actually in the inbox — the throttled path returns a REAL id of an already-ARCHIVED row and a held card is excluded from the inbox list, so gating on the id alone would have minted dead links the app authored itself.

## Related

Deep links are one of two ways to drive Omniscio from outside — the other, which can also read a
result back and is described on the [CLI control](cli-control.md) page, goes over HTTP with a
token. The panels most of these routes land on each have their own page: the note vault is
covered by [KMS](kms.md), team messaging by [Team Chat](team-chat.md), and the email client by
[Supermail](supermail.md). Where a session link is rendered as a clickable result, that is the
[global search](global-search.md) surface.

- [cli-control.md](cli-control.md) — CLI Control does the same things via HTTP (with a token) if you need the response to do something
- **`GET /deep-link/resolve`** (CLI, bearer token) — dry-run any `omniscio://` link and get back the action it resolves to, or a "did you mean" for a wrong guess, without navigating. For a **setting** link it goes further than checking the URL's shape: it looks the setting up in the live catalog, so a link naming a setting that doesn't exist comes back as a failure with the nearest real settings, instead of a green light that dead-ends when you click it. If no app window is open to check against, it says so rather than guessing either way. See [cli-control.md](cli-control.md).
- [global-search.md](global-search.md) — renders `omniscio://session/<id>` as clickable internal links
- [kms.md](kms.md) — the KMS note vault; `omniscio://note/<path>` opens a specific note in its panel
- [team-chat.md](team-chat.md) — the Team Chat feature; `omniscio://chat/<kind>/<wsId>/<channelId>` opens a channel/message
- [supermail.md](supermail.md) — the Supermail email client; `omniscio://supermail/thread/<id>` opens a specific email in its panel
