---
title: Opening a media link (it opens in your own player)
---

# Media link open (mp3 / mp4 / pdf / html → default OS player)

## What it is

### What it is

When an agent's message contains a markdown link pointing at a **binary media file** (`mp3`, `mp4`, `wav`, `mov`, `mkv`, `flac`, `zip`, `7z`, `exe`, `iso`, …) or an **HTML file**, clicking the link hands the file to your operating system's default app for that type. An mp3 opens in your default audio player, an mp4 in your default video player, an `.html` file in your default browser. Omniscio itself does not try to render those files — it asks the OS to open them the same way double-clicking would in File Explorer / Finder.

> **PDFs open in Omniscio's built-in viewer.** Clicking a `.pdf` link in a chat message (or opening a PDF from the file-explorer tree) renders it in an in-app paginated viewer with zoom controls, instead of launching your computer's default PDF app. The rendered text is **selectable and copyable**; a **find bar** (open it with the toolbar's search button or **Ctrl/Cmd+F**) searches the whole document, shows a match count, and jumps between hits with the text highlighted; and a **Convert to Markdown/text** toolbar action extracts the PDF's text on-device — free and private — into a copyable panel with a Markdown / Plain-text toggle. A scanned or image-only PDF that has no selectable text shows a clean "no text found — needs OCR" message rather than an empty result (no OCR engine is bundled). A **Print** toolbar button (printer icon, last in the toolbar) sends the whole document to your printer through the normal print dialog: every page is rendered on-device at 288 DPI (216 DPI past 30 pages) and printed at its **true physical size** — a 4×6 shipping label comes out 4×6, never stretched to the paper — with one PDF page per sheet and no added margins, so the print dialog's own scaling is the only thing that resizes it. It works the same on the desktop app and the phone/web app, sends nothing off your machine, and refuses a document over **200 pages** with a plain message rather than risking the app running out of memory. Print has no keyboard shortcut — Ctrl+P is already claimed by other shortcuts in the app. A **Settings toggle** — "Open PDFs in the built-in viewer" (under the file-viewing settings, default ON) — switches all of this off to fall back to the OS app. **Large PDFs paginate instead of failing.** On the desktop a big PDF now loads a page at a time (bounded memory) rather than showing a "too large to preview" message — whether you open it with the Open-PDF button, drag it in, click a link an agent wrote, or open it from the file tree. On a phone the Open-PDF button opens a large local file the same way (reading it a slice at a time straight from the file); _previewing_ a very large project file over the network still shows the size cap, to avoid pushing a huge file down to your phone.

> **Three more ways to reach the viewer (beyond clicking a link).** (1) **Open PDF…** — a button in the **Drive** panel header opens a native file picker to view any PDF on your computer, even one that isn't in a session or project. The app reads only the file you pick in the OS dialog — it never accepts a file path from the page, so it can't be tricked into reading anything you didn't choose, and protected files (your Omniscio credentials, SSH/cloud keys) are refused. **On a phone or the web app the same button appears**, but there it opens your device's own file picker and reads the PDF entirely inside your browser — the file never travels to the host, so this mobile path can't reach anything on the host machine. (2) **Drag-and-drop** — drop a single PDF anywhere on the app's main content area to view it (a large one opens lazily, same as the picker); dropping a PDF onto the message composer still _attaches_ it to your message instead (the two never collide). (3) **Send to agent** — a button in the PDF viewer starts a **new session** in your current project with the PDF pre-attached as a real `application/pdf` document (the model reads the actual PDF — scanned pages included, no OCR); the session opens with an empty message box so you type your own first question, and the normal send delivers the PDF.

This is distinct from **image links** (`png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `bmp`, `ico`), which still open in the in-app Peek Viewer (see [peek-viewer.md](peek-viewer.md)) — images are meant for inline review and sibling navigation, not OS hand-off.

## Where to find it

In the chat: a **markdown link inside an agent's message** that points at a media file. Clicking it is the whole feature — there is nothing to switch on.

## How it behaves

### How to use it

1. **Open a chat session** where the agent has written a markdown link to a media file, e.g. `[recording](audio/call.mp3)` or `[the PDF report](docs/report.pdf)`.
2. **Click the link.**
3. The file opens in your system default app. Omniscio stays in the foreground; the player launches alongside.
4. **If the file can't be opened**, a red toast appears in the bottom corner reading `Couldn't open <filename>: <reason>`. Common reasons: `File not found` (the path the agent wrote no longer exists on disk), `No application is associated with this file` (Windows has no default app for that extension), or the operating system's own error string.
5. **If the file is outside the project's scope** — it exists on disk but sits outside the project's allowed folders — you do _not_ get a dead-end error. A confirm dialog appears instead, offering **Open this once**, **Always allow this folder**, or **Cancel**. Opening re-checks the path and hands it to your default app; "Always allow this folder" remembers the folder so future links inside it open without asking. This is the same grant prompt previewable files already get in the Peek Viewer (see [peek-viewer.md](peek-viewer.md)) — extended here to files that open in an outside app.

### What "media file" means here

The routing is by file extension, hard-coded in the renderer:

- **PDF** (in-app built-in viewer by default; OS app when the toggle is off): `pdf` — see the note above.
- **Binary / native** (opens in default app via `shell.openPath`): `mp3`, `mp4`, `wav`, `avi`, `mov`, `mkv`, `flac`, `ogg`, `zip`, `tar`, `gz`, `bz2`, `xz`, `7z`, `rar`, `exe`, `dll`, `so`, `dylib`, `bin`, `doc`, `docx`, `xls`, `xlsx`, `ppt`, `pptx`, `iso`, plus a few more (see `BINARY_EXTENSIONS` in `agent-markdown-path-utils.ts`).
- **Browser** (also opens in default app): `html`, `htm`. Your default browser usually wins; you can also right-click in your OS to pick a different opener.
- **Peek** (stays in-app, NOT this feature): everything else — text files like `.md`, `.ts`, `.py`, `.json`, `.log`, and the image extensions listed above.

### Where the file is looked up

The agent rarely writes absolute paths — most links are relative (`audio/foo.mp3`, `docs/report.pdf`). The main process resolves them in this order:

1. **Worktree first.** If the session was spawned in an isolated git worktree Omniscio manages (`session.worktreePath` is set and the directory still exists on disk), the relative path is resolved against the worktree root. This matches where the agent actually wrote the file.
2. **Agent-detected workdir.** If the session has no managed worktree but the agent has been writing into a git worktree elsewhere (e.g. one the user created by hand outside Omniscio, then started a session that happens to operate there), the main process learns that path from the agent's own `Edit` / `Write` / `NotebookEdit` / `MultiEdit` tool calls and resolves relative paths against it. Detection is git-verified — the path must appear in `git worktree list` and must not be the project's main checkout — so a stray write to an unrelated directory cannot redirect link resolution.
3. **Project working directory.** Otherwise (or if both worktree lookups miss), the relative path is resolved against the session's project folder. For the built-in **Claude** virtual project, that's `~/Claude` — virtual projects are mapped to their real on-disk folder before the lookup, so links work the same way they do for regular projects.
4. **Breadth-first fallback by name.** If the relative path doesn't exist at any of those locations, Omniscio searches the project tree by name (depth 15, skipping hidden directories like `.git`, `node_modules`). This catches the common case where the agent wrote `report.pdf` but the file is actually at `subdir/report.pdf`.
5. **Folders get the same search (since 2026-09-11).** Until then this search matched **files only** — it walked _into_ every folder but never compared the folder's own name — so a link to a folder that lived somewhere other than where the link pointed had no recovery at all and dead-ended immediately. A folder link like `inbound/` now resolves to `Proyecto A/inbound` the same way a misplaced file does. The search is the **last** thing tried, so a link that points at something real costs nothing extra.

Whichever path resolves first is then checked against a **security gate**: the resolved absolute path must sit under the project's working directory (or one of the project's recognized extra roots, like `~/.claude/` for the Claude virtual project). A relative link that tries to escape — say, `../../../../etc/passwd` — never resolves (the `..` traversal is rejected before it becomes a candidate) and surfaces `File not found`. The `Path is outside project scope` gate fires on an **absolute** link that lands outside the project; rather than dead-ending on a red toast, the renderer turns that specific rejection into the **grant confirm dialog** above (Open once / Always allow folder / Cancel), so you can choose to open a file that's simply outside the current project.

### When it genuinely isn't there

If every lookup above misses, the toast **names the thing and says where it looked** — `Couldn't find "inbound" anywhere in this project.` It says _"in this project"_ only when the tree search actually ran, and never claims to have searched _everywhere_, because the walk is depth- and breadth-capped. The message deliberately does **not** guess whether the thing was a file or a folder — the same toast serves project docs and binary files, so a missing PDF is never reported as a missing folder.

Two buttons ride along:

- **Browse files** opens the project in the in-app file explorer, so a dead link leaves you somewhere useful instead of nowhere. **Desktop only** — the file explorer doesn't exist in the mobile web embed, so on a phone the button is absent rather than dead.
- **Send report** files a bug report with the path trace and diagnostics. This is the button that produced the report behind the 2026-09-11 folder-link fix, and it is kept on every surface including mobile.

And when the search _does_ find the thing somewhere other than where the link pointed, Omniscio opens it **and tells you**: a quiet follow-up note reads `Opened "Proyecto A/inbound" instead of the linked path`. With two same-named folders in one project, that note is the only thing telling you which one you just got. It fires only on a genuine redirect — a normal, correct click stays silent.

Absolute paths (e.g. `[clip](C:\videos\clip.mp4)`) skip the relative-resolution chain entirely but still go through the same security gate. An absolute path inside the project scope opens directly; one outside it now prompts the grant confirm dialog (Open once / Always allow folder / Cancel) instead of silently refusing.

### Limitations and v1 notes

- **No "open in a specific app" override.** Omniscio always hands off to whatever your OS has registered as default for that extension. To change that, change your OS default (Windows: right-click → Open with → Choose another app; macOS: Get Info → Open with).
- **No URL handling.** `http://` / `https://` links are out of scope here — those go through Electron's `shell.openExternal` and have always worked. This feature is only for **file** links the agent wrote.
- **Office files in this feature ≠ chat-attachment Office files.** Clicking a markdown link to a `.docx` an agent referenced opens it in Word the same as any other binary. The separate "Office files blocked at attach" rule (see [chat-attachments.md](chat-attachments.md)) applies only to the composer's attach flow, not to agent-written links.
- **`shell.openPath` is best-effort.** On unusual systems (no default app, broken file association, permission denial), the error string comes from the OS and may not be self-explanatory. The toast surfaces it verbatim so you can search for it — Omniscio does not translate or wrap it.
- **Images stay on the in-app Peek Viewer**, not this feature. If you want an image to open in your default image viewer, drag the link from chat to your desktop and double-click, or use your OS file-explorer at the path the agent printed.

## For agents

### How it works

Two layers cooperate:

1. **Renderer click handler** — [`agent-markdown-helpers.tsx`](/src/renderer/src/components/ui/agent-markdown-helpers.tsx)'s `CopyableLink` looks at the link's extension via `getClickRoute(href)` from [`agent-markdown-path-utils.ts`](/src/renderer/src/components/ui/agent-markdown-path-utils.ts). If the route is `'native'` (binary media) or `'browser'` (HTML), the click goes through the `openHrefInDefaultApp` helper, which sends `{ sessionId, href }` to the main process via the `IPC.FILES_OPEN_HREF` channel and awaits the result. On success, no toast — the file opens, the user sees their player. On failure the helper branches: if the error is exactly `Path is outside project scope` (the shared `PATH_OUTSIDE_PROJECT_SCOPE_ERROR` constant in [`file-scope-error.ts`](/src/renderer/src/lib/file-scope-error.ts)) **and** the href is absolute, it opens the outside-scope grant dialog ([`OutsideScopeGrantDialog`](/src/renderer/src/components/ui/OutsideScopeGrantDialog.tsx), driven by `outside-scope-grant-store`) with a retry that re-runs the open **with grant-recovery disabled** so it can never loop; any other failure extracts the basename of the href and shows the usual `Couldn't open ${filename}: ${error}` toast. The by-path callers (inline path chips, Project Docs) get the identical recovery through `attemptOpenPath` in [`open-path-with-report.ts`](/src/renderer/src/lib/open-path-with-report.ts).

2. **Main process handler** — [`file-handlers.ts`](/src/main/ipc/file-handlers.ts) registers the `FILES_OPEN_HREF` handler with `wrapHandler` and Zod validation (`openHrefSchema` in [`ipc-schemas.ts`](/src/shared/ipc-schemas.ts) — `sessionId` 1–200 chars, `href` 1–2000 chars). The handler looks up the session and project from SQLite, runs the worktree → project-workdir → BFS resolution chain (or accepts an absolute path directly with a `stat` check), passes the resolved path through `scopeAbsolutePathToProject` (the shared security gate used by `FILES_OPEN_PATH` and `FILES_OPEN_NATIVE`), then calls Electron's `shell.openPath(scopedPath)`. `shell.openPath` returns an empty string on success and the OS error message on failure; the handler surfaces that error string verbatim in the failure envelope.

### Why a dedicated channel instead of the existing ones

`FILES_OPEN_PATH` requires an absolute path and is meant for callers that have already done the resolution (e.g. file-explorer right-click). `FILES_OPEN_NATIVE` is shaped for the file-explorer tree, taking a relative path plus an explicit `projectId` and not knowing anything about worktrees. Markdown clicks have neither: the renderer can't compute the right absolute path because virtual projects' `folderPath` is a sentinel (`__claude__`), not a real directory, and worktree-isolated sessions live at `session.worktreePath`, not `project.folderPath`. `FILES_OPEN_HREF` takes `{ sessionId, href }` and owns the whole resolution chain in one place — which also means the same call handles both binary-media (`'native'` route) and HTML (`'browser'` route) links, with identical failure surfaces.

## Related

### Related pages

- [peek-viewer.md](peek-viewer.md) — the in-app file preview that handles image links and text-file paths.
- [chat-attachments.md](chat-attachments.md) — three-tier handler when the user attaches files in the composer (not the same as clicking agent-written links).
- [tray-and-window.md](tray-and-window.md) — `shell.openExternal` rules for `http(s)` URLs (a different mechanism than `shell.openPath` for files).

