diff --git a/t3code/apps/desktop/src/app/DesktopApp.ts b/t3code/apps/desktop/src/app/DesktopApp.ts
index b98175529..753da00a2 100644
--- a/t3code/apps/desktop/src/app/DesktopApp.ts
+++ b/t3code/apps/desktop/src/app/DesktopApp.ts
@@ -6,6 +6,7 @@ import * as Random from "effect/Random";
import * as Ref from "effect/Ref";
import * as NetService from "@t3tools/shared/Net";
+import * as ElectronDeepLink from "../electron/ElectronDeepLink.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
@@ -215,6 +216,9 @@ const startup = Effect.gen(function* () {
yield* appIdentity.configure;
yield* applicationMenu.configure;
yield* electronProtocol.registerDesktopFileProtocol;
+ yield* Effect.service(ElectronDeepLink.ElectronDeepLink).pipe(
+ Effect.flatMap((deepLink) => deepLink.register),
+ );
yield* updates.configure;
yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
}).pipe(Effect.withSpan("desktop.startup"));
diff --git a/t3code/apps/desktop/src/electron/.contributor.json b/t3code/apps/desktop/src/electron/.contributor.json
new file mode 100644
index 000000000..c86bb3512
--- /dev/null
+++ b/t3code/apps/desktop/src/electron/.contributor.json
@@ -0,0 +1 @@
+{"agent": "Hermes Agent", "initialized_with": "Add deep linking support to the Electron desktop app by registering a custom t3code:// protocol, enabling navigation from external sources (e.g., browser links) to specific projects, chat threads, or settings. URL patterns: t3code://open/project?path=/path/to/repo, t3code://chat/thread?id=abc123, t3code://settings. Must register protocol handler, parse URLs and route via IPC, handle app lifecycle (focus existing or launch + navigate), validate against path traversal, show error on invalid URLs, work cross-platform (macOS/Windows/Linux).", "timestamp": "2026-05-28T03:00:00Z"}
\ No newline at end of file
diff --git a/t3code/apps/desktop/src/electron/ElectronDeepLink.test.ts b/t3code/apps/desktop/src/electron/ElectronDeepLink.test.ts
new file mode 100644
index 000000000..9feb7edf1
--- /dev/null
+++ b/t3code/apps/desktop/src/electron/ElectronDeepLink.test.ts
@@ -0,0 +1,168 @@
+import { vi } from "vitest";
+import { assert, describe, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Option from "effect/Option";
+
+const { setAsDefaultProtocolClientMock, onMock, browserWindowGetAllWindowsMock } = vi.hoisted(
+ () => ({
+ setAsDefaultProtocolClientMock: vi.fn(),
+ onMock: vi.fn(),
+ browserWindowGetAllWindowsMock: vi.fn(() => []),
+ }),
+);
+
+vi.mock("electron", () => ({
+ app: {
+ setAsDefaultProtocolClient: setAsDefaultProtocolClientMock,
+ on: onMock,
+ },
+ BrowserWindow: {
+ getAllWindows: browserWindowGetAllWindowsMock,
+ },
+}));
+
+import * as ElectronDeepLink from "./ElectronDeepLink.ts";
+
+function runToExit(effect: Effect.Effect): Exit.Exit {
+ return Effect.runSyncExit(effect);
+}
+
+describe("ElectronDeepLink", () => {
+ describe("parseDeepLinkUrl", () => {
+ it("parses settings route", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl("t3code://settings"),
+ );
+ assert.isTrue(Exit.isSuccess(exit));
+ if (Exit.isSuccess(exit)) {
+ assert.equal(exit.value.kind, "open-settings");
+ }
+ });
+
+ it("parses settings route with trailing slash", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl("t3code://settings/"),
+ );
+ assert.isTrue(Exit.isSuccess(exit));
+ if (Exit.isSuccess(exit)) {
+ assert.equal(exit.value.kind, "open-settings");
+ }
+ });
+
+ it("parses open/project route", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl(
+ "t3code://open/project?path=/home/user/my-project",
+ ),
+ );
+ assert.isTrue(Exit.isSuccess(exit));
+ if (Exit.isSuccess(exit)) {
+ assert.equal(exit.value.kind, "open-project");
+ assert.equal(exit.value.path, "home/user/my-project");
+ }
+ });
+
+ it("parses chat/thread route", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl("t3code://chat/thread?id=abc123"),
+ );
+ assert.isTrue(Exit.isSuccess(exit));
+ if (Exit.isSuccess(exit)) {
+ assert.equal(exit.value.kind, "open-thread");
+ assert.equal(exit.value.id, "abc123");
+ }
+ });
+
+ it("fails on unsupported protocol", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl("https://example.com"),
+ );
+ assert.isTrue(Exit.isFailure(exit));
+ });
+
+ it("fails on missing id for chat/thread", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl("t3code://chat/thread"),
+ );
+ assert.isTrue(Exit.isFailure(exit));
+ });
+
+ it("fails on missing path for open/project", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl("t3code://open/project"),
+ );
+ assert.isTrue(Exit.isFailure(exit));
+ });
+
+ it("fails on path traversal in project path", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl(
+ "t3code://open/project?path=../../etc/passwd",
+ ),
+ );
+ assert.isTrue(Exit.isFailure(exit));
+ });
+
+ it("rejects malformed URL", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl("not-a-url"),
+ );
+ assert.isTrue(Exit.isFailure(exit));
+ });
+
+ it("rejects unrecognized route", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl("t3code://unknown/route"),
+ );
+ assert.isTrue(Exit.isFailure(exit));
+ });
+
+ it("rejects empty id in chat/thread", () => {
+ const exit = runToExit(
+ ElectronDeepLink.parseDeepLinkUrl("t3code://chat/thread?id="),
+ );
+ assert.isTrue(Exit.isFailure(exit));
+ });
+ });
+
+ describe("validateDeepLinkPath", () => {
+ it("accepts normal paths with leading slash", () => {
+ const result = ElectronDeepLink.validateDeepLinkPath("path/to/repo");
+ assert.isTrue(Option.isSome(result));
+ if (Option.isSome(result)) {
+ assert.equal(result.value, "path/to/repo");
+ }
+ });
+
+ it("normalizes redundant slashes", () => {
+ const result = ElectronDeepLink.validateDeepLinkPath(
+ "home/user//repo",
+ );
+ assert.isTrue(Option.isSome(result));
+ if (Option.isSome(result)) {
+ assert.equal(result.value, "home/user/repo");
+ }
+ });
+
+ it("rejects path traversal", () => {
+ const result = ElectronDeepLink.validateDeepLinkPath("../etc/passwd");
+ assert.isTrue(Option.isNone(result));
+ });
+
+ it("rejects deep path traversal", () => {
+ const result = ElectronDeepLink.validateDeepLinkPath(
+ "a/b/../../../../etc/passwd",
+ );
+ assert.isTrue(Option.isNone(result));
+ });
+
+ it("handles single segment", () => {
+ const result = ElectronDeepLink.validateDeepLinkPath("project");
+ assert.isTrue(Option.isSome(result));
+ if (Option.isSome(result)) {
+ assert.equal(result.value, "project");
+ }
+ });
+ });
+});
diff --git a/t3code/apps/desktop/src/electron/ElectronDeepLink.ts b/t3code/apps/desktop/src/electron/ElectronDeepLink.ts
new file mode 100644
index 000000000..7a77fad8c
--- /dev/null
+++ b/t3code/apps/desktop/src/electron/ElectronDeepLink.ts
@@ -0,0 +1,194 @@
+import * as Context from "effect/Context";
+import * as Data from "effect/Data";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Scope from "effect/Scope";
+
+import * as Electron from "electron";
+
+import * as ElectronApp from "./ElectronApp.ts";
+
+export const DEEP_LINK_SCHEME = "t3code";
+
+export const DEEP_LINK_IPC_CHANNEL = "desktop:deep-link";
+
+export const SUPPORTED_DEEP_LINK_PATTERNS = [
+ "t3code://open/project?path=",
+ "t3code://chat/thread?id=",
+ "t3code://settings",
+] as const;
+
+// ── Errors ────────────────────────────────────────────────────────────
+
+export class DeepLinkUrlParseError extends Data.TaggedError("DeepLinkUrlParseError")<{
+ readonly url: string;
+ readonly message: string;
+}> {
+ override get message() {
+ return `Failed to parse deep link URL "${this.url}": ${this.message}`;
+ }
+}
+
+export class DeepLinkPathTraversalError extends Data.TaggedError("DeepLinkPathTraversalError")<{
+ readonly path: string;
+}> {
+ override get message() {
+ return `Path traversal detected in deep link path: "${this.path}"`;
+ }
+}
+
+// ── Route Types ───────────────────────────────────────────────────────
+
+export type DeepLinkRoute =
+ | { readonly kind: "open-project"; readonly path: string }
+ | { readonly kind: "open-thread"; readonly id: string }
+ | { readonly kind: "open-settings" };
+
+// ── Service Shape ─────────────────────────────────────────────────────
+
+export interface ElectronDeepLinkShape {
+ readonly register: Effect.Effect;
+}
+
+export class ElectronDeepLink extends Context.Service<
+ ElectronDeepLink,
+ ElectronDeepLinkShape
+>()("t3/desktop/electron/DeepLink") {}
+
+// ── URL Parsing ───────────────────────────────────────────────────────
+
+export function parseDeepLinkUrl(
+ urlString: string,
+): Effect.Effect {
+ return Effect.try({
+ try: () => new URL(urlString),
+ catch: (cause) =>
+ new DeepLinkUrlParseError({
+ url: urlString,
+ message: cause instanceof Error ? cause.message : String(cause),
+ }),
+ }).pipe(Effect.flatMap((url) => {
+ if (url.protocol !== `${DEEP_LINK_SCHEME}:`) {
+ return Effect.fail(
+ new DeepLinkUrlParseError({
+ url: urlString,
+ message: `Unsupported protocol: "${url.protocol}". Expected "${DEEP_LINK_SCHEME}:".`,
+ }),
+ );
+ }
+
+ const hostname = url.hostname;
+ const pathname = url.pathname.replace(/^\/+/, "");
+
+ if (hostname === "settings" || pathname === "settings") {
+ return Effect.succeed({ kind: "open-settings" } as DeepLinkRoute);
+ }
+
+ if (hostname === "chat" || pathname.startsWith("chat/")) {
+ const id = url.searchParams.get("id");
+ if (!id || id.trim().length === 0) {
+ return Effect.fail(
+ new DeepLinkUrlParseError({
+ url: urlString,
+ message: 'Missing or empty "id" parameter for chat/thread route.',
+ }),
+ );
+ }
+ return Effect.succeed({ kind: "open-thread", id: id.trim() } as DeepLinkRoute);
+ }
+
+ if (hostname === "open" && pathname.startsWith("project")) {
+ const rawPath = url.searchParams.get("path");
+ if (!rawPath || rawPath.trim().length === 0) {
+ return Effect.fail(
+ new DeepLinkUrlParseError({
+ url: urlString,
+ message: 'Missing or empty "path" parameter for open/project route.',
+ }),
+ );
+ }
+ const validated = validateDeepLinkPath(rawPath.trim());
+ if (Option.isNone(validated)) {
+ return Effect.fail(new DeepLinkPathTraversalError({ path: rawPath.trim() }));
+ }
+ return Effect.succeed({ kind: "open-project", path: validated.value } as DeepLinkRoute);
+ }
+
+ return Effect.fail(
+ new DeepLinkUrlParseError({
+ url: urlString,
+ message: `Unrecognized deep link route: "${hostname}/${pathname}". Supported patterns: ${SUPPORTED_DEEP_LINK_PATTERNS.join(", ")}`,
+ }),
+ );
+ }));
+}
+
+// ── Path Validation ───────────────────────────────────────────────────
+
+export function validateDeepLinkPath(rawPath: string): Option.Option {
+ // Normalize and prevent path traversal
+ const segments: string[] = [];
+ for (const segment of rawPath.split("/")) {
+ if (segment.length === 0 || segment === ".") {
+ continue;
+ }
+ if (segment === "..") {
+ return Option.none();
+ }
+ segments.push(segment);
+ }
+ const normalized = segments.join("/");
+ return Option.some(normalized);
+}
+
+// ── Implementation ────────────────────────────────────────────────────
+
+const make = Effect.gen(function* () {
+ const electronApp = yield* ElectronApp.ElectronApp;
+
+ const register = Effect.gen(function* () {
+ // Register as default protocol client so OS routes t3code:// URLs to this app
+ yield* Effect.sync(() => {
+ Electron.app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME);
+ });
+
+ // macOS: open-url event fires when the OS opens a t3code:// URL
+ yield* electronApp.on("open-url", (_event: Electron.Event, url: string) => {
+ const result = Effect.runSync(Effect.either(parseDeepLinkUrl(url)));
+ if (result._tag === "Right") {
+ for (const window of Electron.BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) {
+ window.webContents.send(DEEP_LINK_IPC_CHANNEL, result.right);
+ }
+ }
+ }
+ });
+
+ // Windows/Linux: second-instance fires when a second instance passes a URL
+ yield* electronApp.on(
+ "second-instance",
+ (_event: Electron.Event, argv: readonly string[]) => {
+ // Find the first t3code:// URL in the argv
+ const deepLinkArg = argv.find((arg) => arg.startsWith(`${DEEP_LINK_SCHEME}:`));
+ if (deepLinkArg) {
+ const result = Effect.runSync(Effect.either(parseDeepLinkUrl(deepLinkArg)));
+ if (result._tag === "Right") {
+ // Focus the existing window
+ const windows = Electron.BrowserWindow.getAllWindows();
+ const mainWindow = windows.find((w) => !w.isDestroyed());
+ if (mainWindow) {
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ mainWindow.focus();
+ mainWindow.webContents.send(DEEP_LINK_IPC_CHANNEL, result.right);
+ }
+ }
+ }
+ },
+ );
+ }).pipe(Effect.withSpan("desktop.electron.deepLink.register"));
+
+ return ElectronDeepLink.of({ register });
+});
+
+export const layer = Layer.effect(ElectronDeepLink, make);
diff --git a/t3code/apps/desktop/src/ipc/channels.ts b/t3code/apps/desktop/src/ipc/channels.ts
index 2715b20cb..0f362cfe2 100644
--- a/t3code/apps/desktop/src/ipc/channels.ts
+++ b/t3code/apps/desktop/src/ipc/channels.ts
@@ -33,3 +33,4 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod
export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
+export const DEEP_LINK_CHANNEL = "desktop:deep-link";
diff --git a/t3code/apps/desktop/src/main.ts b/t3code/apps/desktop/src/main.ts
index 0bc1badff..c20743335 100644
--- a/t3code/apps/desktop/src/main.ts
+++ b/t3code/apps/desktop/src/main.ts
@@ -18,6 +18,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts";
import * as ElectronApp from "./electron/ElectronApp.ts";
import * as ElectronDialog from "./electron/ElectronDialog.ts";
import * as ElectronMenu from "./electron/ElectronMenu.ts";
+import * as ElectronDeepLink from "./electron/ElectronDeepLink.ts";
import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts";
import * as ElectronShell from "./electron/ElectronShell.ts";
@@ -97,6 +98,7 @@ const electronLayer = Layer.mergeAll(
ElectronApp.layer,
ElectronDialog.layer,
ElectronMenu.layer,
+ ElectronDeepLink.layer,
ElectronProtocol.layer,
DesktopSecretStorage.layer,
ElectronShell.layer,
diff --git a/t3code/apps/desktop/src/preload.ts b/t3code/apps/desktop/src/preload.ts
index 173be8fb5..d80516a2f 100644
--- a/t3code/apps/desktop/src/preload.ts
+++ b/t3code/apps/desktop/src/preload.ts
@@ -95,6 +95,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
items,
...(position === undefined ? {} : { position }),
}),
+ onDeepLink: (listener) => {
+ const wrappedListener = (_event: Electron.IpcRendererEvent, route: unknown) => {
+ if (typeof route !== "object" || route === null) return;
+ listener(route as { kind: string; path?: string; id?: string });
+ };
+
+ ipcRenderer.on(IpcChannels.DEEP_LINK_CHANNEL, wrappedListener);
+ return () => {
+ ipcRenderer.removeListener(IpcChannels.DEEP_LINK_CHANNEL, wrappedListener);
+ };
+ },
openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
diff --git a/t3code/packages/contracts/src/ipc.ts b/t3code/packages/contracts/src/ipc.ts
index c0515aa9b..17a896e89 100644
--- a/t3code/packages/contracts/src/ipc.ts
+++ b/t3code/packages/contracts/src/ipc.ts
@@ -414,6 +414,7 @@ export interface DesktopBridge {
position?: { x: number; y: number },
) => Promise;
openExternal: (url: string) => Promise;
+ onDeepLink: (listener: (route: { kind: string; path?: string; id?: string }) => void) => () => void;
onMenuAction: (listener: (action: string) => void) => () => void;
getUpdateState: () => Promise;
setUpdateChannel: (channel: DesktopUpdateChannel) => Promise;