Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/deep-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
title: Deep Links
description: Navigate to specific agents, tabs, and groups using maestro:// URLs from external apps, scripts, and OS notifications.
icon: link
---

# Deep Links

Maestro registers the `maestro://` URL protocol, enabling navigation to specific agents, tabs, and groups from external tools, scripts, shell commands, and OS notification clicks.

## URL Format

```
maestro://[action]/[parameters]
```

### Available Actions

| URL | Action |
| ------------------------------------------- | ------------------------------------------ |
| `maestro://focus` | Bring Maestro window to foreground |
| `maestro://session/{sessionId}` | Navigate to an agent |
| `maestro://session/{sessionId}/tab/{tabId}` | Navigate to a specific tab within an agent |
| `maestro://group/{groupId}` | Expand a group and focus its first agent |

IDs containing special characters (`/`, `?`, `#`, `%`, etc.) are automatically URI-encoded and decoded.

## Usage

### From Terminal

```bash
# macOS
open "maestro://session/abc123"
open "maestro://session/abc123/tab/def456"
open "maestro://group/my-group-id"
open "maestro://focus"

# Linux
xdg-open "maestro://session/abc123"

# Windows
start maestro://session/abc123
```

### OS Notification Clicks

When Maestro is running in the background and an agent completes a task, the OS notification is automatically linked to the originating agent and tab. Clicking the notification brings Maestro to the foreground and navigates directly to that agent's tab.

This works out of the box — no configuration needed. Ensure **OS Notifications** are enabled in Settings.

### Template Variables

Deep link URLs are available as template variables in system prompts, custom AI commands, and Auto Run documents:

| Variable | Description | Example Value |
| --------------------- | ---------------------------------------------- | ------------------------------------- |
| `{{AGENT_DEEP_LINK}}` | Link to the current agent | `maestro://session/abc123` |
| `{{TAB_DEEP_LINK}}` | Link to the current agent + active tab | `maestro://session/abc123/tab/def456` |
| `{{GROUP_DEEP_LINK}}` | Link to the agent's group (empty if ungrouped) | `maestro://group/grp789` |

These variables can be used in:

- **System prompts** — give AI agents awareness of their own deep link for cross-referencing
- **Custom AI commands** — include deep links in generated output
- **Auto Run documents** — reference agents in batch automation workflows
- **Custom notification commands** — include deep links in TTS or logging scripts

### From Scripts and External Tools

Any application can launch Maestro deep links by opening the URL. This enables integrations like:

- CI/CD pipelines that open a specific agent after deployment
- Shell scripts that navigate to a group after batch operations
- Alfred/Raycast workflows for quick agent access
- Bookmarks for frequently-used agents

## Platform Behavior

| Platform | Mechanism |
| ----------------- | ----------------------------------------------------------------------------- |
| **macOS** | `app.on('open-url')` delivers the URL to the running instance |
| **Windows/Linux** | `app.on('second-instance')` delivers the URL via argv to the primary instance |
| **Cold start** | URL is buffered and processed after the window is ready |

Maestro uses a single-instance lock — opening a deep link when Maestro is already running delivers the URL to the existing instance rather than launching a new one.

<Note>
In development mode, protocol registration is skipped by default to avoid overriding the production app's handler. Set `REGISTER_DEEP_LINKS_IN_DEV=1` to enable it during development.
</Note>

## Related

- [Configuration](./configuration) — OS notification settings
- [General Usage](./general-usage) — Core UI and workflow patterns
- [MCP Server](./mcp-server) — Connect AI applications to Maestro
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
},
{
"group": "Integrations",
"pages": ["mcp-server"],
"pages": ["mcp-server", "deep-links"],
"icon": "plug"
},
{
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@
"npmRebuild": false,
"appId": "com.maestro.app",
"productName": "Maestro",
"protocols": [
{
"name": "Maestro",
"schemes": ["maestro"]
}
],
"publish": {
"provider": "github",
"owner": "RunMaestro",
Expand Down
141 changes: 141 additions & 0 deletions src/__tests__/main/deep-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Tests for deep link URL parsing
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock electron before importing the module under test
vi.mock('electron', () => ({
app: {
isPackaged: false,
setAsDefaultProtocolClient: vi.fn(),
requestSingleInstanceLock: vi.fn().mockReturnValue(true),
on: vi.fn(),
quit: vi.fn(),
},
BrowserWindow: {
getAllWindows: vi.fn().mockReturnValue([]),
},
}));

vi.mock('../../main/utils/logger', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));

vi.mock('../../main/utils/safe-send', () => ({
isWebContentsAvailable: vi.fn().mockReturnValue(true),
}));

import { parseDeepLink } from '../../main/deep-links';

describe('parseDeepLink', () => {
describe('focus action', () => {
it('should parse maestro://focus', () => {
expect(parseDeepLink('maestro://focus')).toEqual({ action: 'focus' });
});

it('should parse empty path as focus', () => {
expect(parseDeepLink('maestro://')).toEqual({ action: 'focus' });
});

it('should parse protocol-only as focus', () => {
expect(parseDeepLink('maestro:')).toEqual({ action: 'focus' });
});
});

describe('session action', () => {
it('should parse session URL', () => {
expect(parseDeepLink('maestro://session/abc123')).toEqual({
action: 'session',
sessionId: 'abc123',
});
});

it('should parse session URL with tab', () => {
expect(parseDeepLink('maestro://session/abc123/tab/tab456')).toEqual({
action: 'session',
sessionId: 'abc123',
tabId: 'tab456',
});
});

it('should decode URI-encoded session IDs', () => {
expect(parseDeepLink('maestro://session/session%20with%20space')).toEqual({
action: 'session',
sessionId: 'session with space',
});
});

it('should decode URI-encoded tab IDs', () => {
expect(parseDeepLink('maestro://session/abc/tab/tab%2Fslash')).toEqual({
action: 'session',
sessionId: 'abc',
tabId: 'tab/slash',
});
});

it('should return null for session without ID', () => {
expect(parseDeepLink('maestro://session')).toBeNull();
expect(parseDeepLink('maestro://session/')).toBeNull();
});

it('should ignore extra path segments after tab ID', () => {
const result = parseDeepLink('maestro://session/abc/tab/tab1/extra/stuff');
expect(result).toEqual({
action: 'session',
sessionId: 'abc',
tabId: 'tab1',
});
});
});

describe('group action', () => {
it('should parse group URL', () => {
expect(parseDeepLink('maestro://group/grp789')).toEqual({
action: 'group',
groupId: 'grp789',
});
});

it('should decode URI-encoded group IDs', () => {
expect(parseDeepLink('maestro://group/group%20name')).toEqual({
action: 'group',
groupId: 'group name',
});
});

it('should return null for group without ID', () => {
expect(parseDeepLink('maestro://group')).toBeNull();
expect(parseDeepLink('maestro://group/')).toBeNull();
});
});

describe('Windows compatibility', () => {
it('should handle Windows maestro: prefix (no double slash)', () => {
expect(parseDeepLink('maestro:session/abc123')).toEqual({
action: 'session',
sessionId: 'abc123',
});
});

it('should handle Windows focus without double slash', () => {
expect(parseDeepLink('maestro:focus')).toEqual({ action: 'focus' });
});
});

describe('error handling', () => {
it('should return null for unrecognized resource', () => {
expect(parseDeepLink('maestro://unknown/abc')).toBeNull();
});

it('should return null for completely malformed URLs', () => {
// parseDeepLink is tolerant of most inputs, but unrecognized resources return null
expect(parseDeepLink('maestro://settings')).toBeNull();
});
});
});
62 changes: 61 additions & 1 deletion src/__tests__/main/ipc/handlers/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ipcMain } from 'electron';
const mocks = vi.hoisted(() => ({
mockNotificationShow: vi.fn(),
mockNotificationIsSupported: vi.fn().mockReturnValue(true),
mockNotificationOn: vi.fn(),
}));

// Mock electron with a proper class for Notification
Expand All @@ -29,6 +30,9 @@ vi.mock('electron', () => {
show() {
mocks.mockNotificationShow();
}
on(event: string, handler: () => void) {
mocks.mockNotificationOn(event, handler);
}
static isSupported() {
return mocks.mockNotificationIsSupported();
}
Expand All @@ -55,6 +59,15 @@ vi.mock('../../../../main/utils/logger', () => ({
},
}));

// Mock deep-links module (used by notification click handler)
vi.mock('../../../../main/deep-links', () => ({
parseDeepLink: vi.fn((url: string) => {
if (url.includes('session/')) return { action: 'session', sessionId: 'test-session' };
return { action: 'focus' };
}),
dispatchDeepLink: vi.fn(),
}));

// Mock child_process - must include default export
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
Expand Down Expand Up @@ -99,6 +112,8 @@ import {
describe('Notification IPC Handlers', () => {
let handlers: Map<string, Function>;

const mockGetMainWindow = vi.fn().mockReturnValue(null);

beforeEach(() => {
vi.clearAllMocks();
resetNotificationState();
Expand All @@ -107,13 +122,14 @@ describe('Notification IPC Handlers', () => {
// Reset mocks
mocks.mockNotificationIsSupported.mockReturnValue(true);
mocks.mockNotificationShow.mockClear();
mocks.mockNotificationOn.mockClear();

// Capture registered handlers
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => {
handlers.set(channel, handler);
});

registerNotificationsHandlers();
registerNotificationsHandlers({ getMainWindow: mockGetMainWindow });
});

afterEach(() => {
Expand Down Expand Up @@ -186,6 +202,50 @@ describe('Notification IPC Handlers', () => {
});
});

describe('notification:show click-to-navigate', () => {
it('should register click handler when sessionId is provided', async () => {
const handler = handlers.get('notification:show')!;
await handler({}, 'Title', 'Body', 'session-123');

expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function));
});

it('should register click handler when sessionId and tabId are provided', async () => {
const handler = handlers.get('notification:show')!;
await handler({}, 'Title', 'Body', 'session-123', 'tab-456');

expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function));
});

it('should URI-encode sessionId and tabId in deep link URL', async () => {
const { parseDeepLink } = await import('../../../../main/deep-links');
const handler = handlers.get('notification:show')!;
await handler({}, 'Title', 'Body', 'id/with/slashes', 'tab?special');

// Trigger the click handler
const clickHandler = mocks.mockNotificationOn.mock.calls[0][1];
clickHandler();

expect(parseDeepLink).toHaveBeenCalledWith(
`maestro://session/${encodeURIComponent('id/with/slashes')}/tab/${encodeURIComponent('tab?special')}`
);
});

it('should not register click handler when sessionId is not provided', async () => {
const handler = handlers.get('notification:show')!;
await handler({}, 'Title', 'Body');

expect(mocks.mockNotificationOn).not.toHaveBeenCalled();
});

it('should not register click handler when sessionId is undefined', async () => {
const handler = handlers.get('notification:show')!;
await handler({}, 'Title', 'Body', undefined, undefined);

expect(mocks.mockNotificationOn).not.toHaveBeenCalled();
});
});

describe('notification:stopSpeak', () => {
it('should return error when no active notification process', async () => {
const handler = handlers.get('notification:stopSpeak')!;
Expand Down
Loading
Loading