Deep Links in ToolHive Studio
Deep links allow external systems (browsers, terminals, other apps) to trigger navigation inside ToolHive Desktop via the toolhive-gui:// custom protocol.
About this document: Much of the content in the reference docs is the result of research into how other Electron apps implement deep links. Some design decisions are implemented; others describe the intended direction but are not yet in the codebase. The base skill reflects the current implementation. The reference docs reflect the research and design intent — read them with that in mind, and update them when relevant implementation decisions change.
URL Schema
toolhive-gui://v1/<intent>[?<query>]
Examples:
toolhive-gui://v1/open-registry-server-detail?serverName=fetch — open a registry server detail page
toolhive-gui://v1/open-registry-server-install?serverName=fetch — open the registry server detail page and auto-open the install dialog
toolhive-gui://v1/open-registry-skill-detail?namespace=io.github.stacklok&skillName=skill-creator — open a registry skill detail page
toolhive-gui://v1/open-registry-skill-install?namespace=io.github.stacklok&skillName=skill-creator&version=v1.0.0 — open the skill detail page and auto-open the install dialog with the reference (and optional ?version tag) prefilled. Tag-only — OCI digests (sha256:…) are intentionally not supported because safeIdentifier rejects colons; users wanting digest pinning can paste it into the dialog directly.
The v1 segment is the version. The intent is a kebab-case action name. Query params carry intent-specific data.
Current Implementation
Key Files
| File |
Role |
common/deep-links.ts |
Single source of truth. All deep link definitions: intent name, Zod param schema, navigation target. |
main/src/deep-links/parse.ts |
Parses and validates a raw URL string using the schemas from common/deep-links.ts. |
main/src/deep-links/index.ts |
Entry point: extracts URL from argv (Windows/Linux), waits for window ready, dispatches via IPC. |
main/src/deep-links/squirrel.ts |
Squirrel.Windows-specific protocol registration. |
IPC Channel
deep-link-navigation — sent main → renderer as a NavigateTarget ({ to: string; params?: Record<string, string> }).
The renderer receives this and calls the TanStack Router navigate() directly.
How It Works (Current)
- Protocol registration: On app start,
app.setAsDefaultProtocolClient('toolhive-gui') registers the protocol. On Windows with Squirrel, registerProtocolWithSquirrel() is called instead (see squirrel.ts).
- URL extraction: On Windows/Linux, the URL arrives in
process.argv. extractDeepLinkFromArgs() scans for the first toolhive-gui:// argument (safe against argv injection — see patterns doc).
- Parse + validate:
parseDeepLinkUrl() parses the URL and runs it through the Zod discriminated union schema defined in common/deep-links.ts. Invalid links resolve to showNotFound.
- Window ready:
waitForMainWindowReady() polls until the window is visible and not loading before dispatching.
- Dispatch:
resolveDeepLinkTarget() converts the validated intent to a NavigateTarget, which is sent to the renderer via the deep-link-navigation IPC channel.
- Renderer: The renderer listens for
deep-link-navigation and calls navigate(target).
Current Limitations vs. Design Intent
The current implementation only supports read (navigate) operations. The design doc proposes a confirmation flow for write/destructive operations (C/U/D), but this is not yet implemented. The IPC sends a pre-resolved NavigateTarget rather than a raw parsed intent — this simplified the initial implementation. See design doc for the full intended model.
How to Add a New Deep Link
All changes happen in common/deep-links.ts:
// 1. Define the new intent using v1DeepLink()
export const myNewIntent = v1DeepLink({
intent: 'my-new-intent', // kebab-case, matches URL path segment
params: z.object({
someParam: safeIdentifier, // use safeIdentifier for user-supplied strings
}),
navigate: (params) => ({
to: '/some-route/$id', // TanStack Router route
params: { id: params.someParam },
}),
})
// 2. Add to allDeepLinks array
const allDeepLinks = [
openRegistryServerDetail,
showNotFound,
myNewIntent,
] as const
// 3. Add to deepLinkSchema discriminated union
export const deepLinkSchema = z.discriminatedUnion('intent', [
openRegistryServerDetail.schema,
showNotFound.schema,
myNewIntent.schema, // ← add here
])
Test manually:
./node_modules/.bin/electron . "toolhive-gui://v1/my-new-intent?someParam=value"
safeIdentifier is defined as z.string().regex(/^[a-zA-Z0-9_.-]+$/) — use it for any param that could be user-supplied to prevent injection.
Reference Documents
For deeper background, see:
- OS & Packaging Support — Platform-specific registration requirements (Windows, Linux, macOS) and packaging format considerations (Squirrel, Flatpak, .deb, .rpm, .dmg, AppImage, MSIX, etc.). Largely research/prior art.
- Observed Patterns — Patterns from VS Code, GitHub Desktop, Mattermost, Element, and others: URL sanitization, argv injection, security confirmations, waiting-for-readiness patterns, telemetry.
- Design & Decisions — Full design rationale, IPC model, error handling strategy, queue management, testing approach, and the decisions log. Some sections describe planned future behaviour not yet implemented.
1---2name: deep-links3description: Deep links in ToolHive Studio. Use when implementing, debugging, or asking about deep link features (toolhive-gui:// protocol), adding new deep link intents, understanding the deep link architecture, IPC model, or platform/packaging support.4---56# Deep Links in ToolHive Studio78Deep links allow external systems (browsers, terminals, other apps) to trigger navigation inside ToolHive Desktop via the `toolhive-gui://` custom protocol.910> **About this document**: Much of the content in the reference docs is the result of research into how other Electron apps implement deep links. Some design decisions are implemented; others describe the intended direction but are not yet in the codebase. The base skill reflects the current implementation. The reference docs reflect the research and design intent — read them with that in mind, and update them when relevant implementation decisions change.1112---1314## URL Schema1516```17toolhive-gui://v1/<intent>[?<query>]18```1920Examples:2122- `toolhive-gui://v1/open-registry-server-detail?serverName=fetch` — open a registry server detail page23- `toolhive-gui://v1/open-registry-server-install?serverName=fetch` — open the registry server detail page and auto-open the install dialog24- `toolhive-gui://v1/open-registry-skill-detail?namespace=io.github.stacklok&skillName=skill-creator` — open a registry skill detail page25- `toolhive-gui://v1/open-registry-skill-install?namespace=io.github.stacklok&skillName=skill-creator&version=v1.0.0` — open the skill detail page and auto-open the install dialog with the reference (and optional `?version` tag) prefilled. Tag-only — OCI digests (`sha256:…`) are intentionally not supported because `safeIdentifier` rejects colons; users wanting digest pinning can paste it into the dialog directly.2627The `v1` segment is the version. The intent is a kebab-case action name. Query params carry intent-specific data.2829---3031## Current Implementation3233### Key Files3435| File | Role |36| --------------------------------- | -------------------------------------------------------------------------------------------------------- |37| `common/deep-links.ts` | **Single source of truth.** All deep link definitions: intent name, Zod param schema, navigation target. |38| `main/src/deep-links/parse.ts` | Parses and validates a raw URL string using the schemas from `common/deep-links.ts`. |39| `main/src/deep-links/index.ts` | Entry point: extracts URL from argv (Windows/Linux), waits for window ready, dispatches via IPC. |40| `main/src/deep-links/squirrel.ts` | Squirrel.Windows-specific protocol registration. |4142### IPC Channel4344`deep-link-navigation` — sent **main → renderer** as a `NavigateTarget` (`{ to: string; params?: Record<string, string> }`).4546The renderer receives this and calls the TanStack Router `navigate()` directly.4748### How It Works (Current)49501. **Protocol registration**: On app start, `app.setAsDefaultProtocolClient('toolhive-gui')` registers the protocol. On Windows with Squirrel, `registerProtocolWithSquirrel()` is called instead (see `squirrel.ts`).512. **URL extraction**: On Windows/Linux, the URL arrives in `process.argv`. `extractDeepLinkFromArgs()` scans for the first `toolhive-gui://` argument (safe against argv injection — see [patterns doc](references/patterns.md#url-parsing)).523. **Parse + validate**: `parseDeepLinkUrl()` parses the URL and runs it through the Zod discriminated union schema defined in `common/deep-links.ts`. Invalid links resolve to `showNotFound`.534. **Window ready**: `waitForMainWindowReady()` polls until the window is visible and not loading before dispatching.545. **Dispatch**: `resolveDeepLinkTarget()` converts the validated intent to a `NavigateTarget`, which is sent to the renderer via the `deep-link-navigation` IPC channel.556. **Renderer**: The renderer listens for `deep-link-navigation` and calls `navigate(target)`.5657### Current Limitations vs. Design Intent5859The current implementation only supports **read (navigate) operations**. The design doc proposes a confirmation flow for write/destructive operations (C/U/D), but this is not yet implemented. The IPC sends a pre-resolved `NavigateTarget` rather than a raw parsed intent — this simplified the initial implementation. See [design doc](references/design.md) for the full intended model.6061---6263## How to Add a New Deep Link6465All changes happen in **`common/deep-links.ts`**:6667```ts68// 1. Define the new intent using v1DeepLink()69export const myNewIntent = v1DeepLink({70 intent: 'my-new-intent', // kebab-case, matches URL path segment71 params: z.object({72 someParam: safeIdentifier, // use safeIdentifier for user-supplied strings73 }),74 navigate: (params) => ({75 to: '/some-route/$id', // TanStack Router route76 params: { id: params.someParam },77 }),78})7980// 2. Add to allDeepLinks array81const allDeepLinks = [82 openRegistryServerDetail,83 showNotFound,84 myNewIntent,85] as const8687// 3. Add to deepLinkSchema discriminated union88export const deepLinkSchema = z.discriminatedUnion('intent', [89 openRegistryServerDetail.schema,90 showNotFound.schema,91 myNewIntent.schema, // ← add here92])93```9495**Test manually:**9697```bash98./node_modules/.bin/electron . "toolhive-gui://v1/my-new-intent?someParam=value"99```100101> `safeIdentifier` is defined as `z.string().regex(/^[a-zA-Z0-9_.-]+$/)` — use it for any param that could be user-supplied to prevent injection.102103---104105## Reference Documents106107For deeper background, see:108109- **[OS & Packaging Support](references/os-and-packaging.md)** — Platform-specific registration requirements (Windows, Linux, macOS) and packaging format considerations (Squirrel, Flatpak, .deb, .rpm, .dmg, AppImage, MSIX, etc.). Largely research/prior art.110- **[Observed Patterns](references/patterns.md)** — Patterns from VS Code, GitHub Desktop, Mattermost, Element, and others: URL sanitization, argv injection, security confirmations, waiting-for-readiness patterns, telemetry.111- **[Design & Decisions](references/design.md)** — Full design rationale, IPC model, error handling strategy, queue management, testing approach, and the decisions log. Some sections describe planned future behaviour not yet implemented.