OSC 8 Terminal Hyperlinks: How Clickable Links Work in the Terminal

How OSC 8 escape sequences make terminal text clickable, how to emit them from shell scripts and Python, and why detected URLs break when coding agents wrap them.

A terminal can make text clickable in two ways. It can guess, by scanning the screen for strings that look like URLs. Or the program that printed the text can say exactly what is a link, using the OSC 8 escape sequence to attach a target URI to a span of text, much like an HTML anchor.

Guessing works for a bare https:// URL on a single line. OSC 8 is what you need when the visible text is a file name, when a link spans several lines, or when the target should differ from what is printed.

This guide covers the byte format, how terminals store links, how to emit them from shell scripts and Python, which common tools already do, how file:// links behave over SSH, and the security rules worth following when you print text you did not write.

The escape sequence

An OSC 8 hyperlink is two control sequences around ordinary text:

ESC ] 8 ; params ; URI ST  link text  ESC ] 8 ; ; ST
  • ESC ] (bytes 0x1b 0x5d) starts an Operating System Command, and 8 selects hyperlinks.
  • params is an optional list of key=value pairs separated by colons. id is the only one in common use. The field can be empty, but its trailing semicolon cannot be omitted.
  • URI is the link target.
  • ST, the string terminator, is ESC \ (0x1b 0x5c). Most terminals also accept BEL (0x07) here, as they do for other OSC sequences, but ESC \ is the documented form.
  • The second sequence has an empty URI, which closes the link. Everything printed in between is clickable.

Try it:

printf '\033]8;;https://example.com\033\\Example Domain\033]8;;\033\\\n'

A supporting terminal shows "Example Domain", typically underlined on hover, and opens https://example.com on click or modifier-click. A terminal without OSC 8 support should consume the sequence silently and print only the text.

The de facto specification is Egmont Koblinger's Hyperlinks in Terminal Emulators, which terminal authors converged on and which tracks supporting terminals.

The text between the two sequences is ordinary output. It can carry colors and bold, and it does not have to resemble the URI.

How the terminal stores a link

A terminal keeps a grid of cells: a character plus attributes such as foreground color. OSC 8 adds one more attribute. While a link is open, every cell the program writes is tagged with that link. Moving the cursor, changing colors, or printing a newline does not close it. Only the closing sequence does.

Two consequences follow:

  1. A link can cover any cells. If a program prints a long URL while a link is open and the terminal soft-wraps it, both rows carry the link. If the program prints a newline and indentation in the middle, the indentation cells are part of the link as well.
  2. No parsing is involved. The terminal opens the URI it was given. Trailing punctuation, parentheses, and line breaks in the visible text cannot change the target.

Group pieces with id

Full-screen programs often write one logical link in several pieces: a link split across two lines inside a box, or a file name redrawn in segments. A terminal that highlights the whole link on hover needs to know those pieces belong together. The specification treats cells as the same link when they share both URI and id:

printf '\033]8;id=notes;https://example.com/notes\033\\Read the full\033]8;;\033\\\n'
printf '\033]8;id=notes;https://example.com/notes\033\\release notes\033]8;;\033\\\n'

In terminals that implement grouping, hovering either line highlights both. Without an id, each terminal applies its own grouping rules, which may join two unrelated links that happen to share a URL. Use an id that is unique per logical link, and keep : and ; out of it, since they are separators.

Why detected URLs break across lines

Implicit URL detection works on the characters the terminal holds. When a program prints a 140-character URL into a 100-column terminal and lets the terminal wrap it, the terminal knows the second row continues the first. Most terminals record that as a "wrapped" flag on the row, because they also need it for copy and paste and for reflowing text on resize. A detector that follows wrapped rows can reassemble the URL.

Full-screen programs often do not let the terminal wrap. Many coding agents render output through their own layout engine. They break long lines themselves, insert a newline plus indentation to stay inside a margin, and position the cursor explicitly. To the terminal, the second half of the URL is now an unrelated row that starts with spaces. A single-line detector underlines https://github.com/acme/api/pull/1234/fi and opens a page that does not exist.

There are three ways out:

  • The program emits OSC 8. The target is complete regardless of layout. This is the only fully reliable fix, and it is in the program author's hands.
  • The terminal rejoins hard-wrapped URLs heuristically. For example, if a URL-like run reaches the edge of the program's text column, continue it on the next row after skipping indentation. This rescues most agent-wrapped links, but it is best effort and can be fooled by unusual layouts.
  • The user copies the fragments and repairs the URL by hand. This is what the first two options are meant to avoid.

If you maintain a CLI or TUI that prints URLs, emit OSC 8 when stdout is a terminal. Users get correct links in every terminal that supports the sequence and plain text everywhere else.

Emit links from your own tools

Shell

# hyperlink URI TEXT: print TEXT as a link to URI
hyperlink() {
  printf '\033]8;;%s\033\\%s\033]8;;\033\\' "$1" "$2"
}

build_url="https://ci.example.com/builds/1234"

if [ -t 1 ] && [ "${TERM:-dumb}" != dumb ]; then
  hyperlink "$build_url" "build #1234"
  printf '\n'
else
  printf 'build #1234 (%s)\n' "$build_url"
fi

Use \033 rather than \e. The printf built into bash and zsh understands \e, but POSIX does not require it, and /bin/sh on some systems prints it literally.

The fallback branch matters. Pipes, log files, and CI output should not contain escape sequences that nobody will render. When output is not a terminal, print the URL itself.

Python

import os
import socket
import sys
from urllib.parse import quote


def hyperlink(uri: str, text: str, link_id: str | None = None) -> str:
    """Wrap text in an OSC 8 hyperlink."""
    params = f"id={link_id}" if link_id else ""
    return f"\033]8;{params};{uri}\033\\{text}\033]8;;\033\\"


def file_uri(path: str) -> str:
    """Build a file:// URI that names this machine as the host."""
    return f"file://{socket.gethostname()}{quote(os.path.abspath(path))}"


def links_enabled(stream=sys.stdout) -> bool:
    return stream.isatty() and os.environ.get("TERM", "dumb") != "dumb"


if links_enabled():
    print(hyperlink(file_uri("report.html"), "report.html"))
else:
    print(os.path.abspath("report.html"))

The type annotations need Python 3.10 or later. quote() keeps / and percent-encodes spaces and non-ASCII characters as UTF-8. That matters because the specification only permits printable ASCII bytes (32 to 126) in the URI. A path such as /Users/alice/My Notes/ä.md becomes /Users/alice/My%20Notes/%C3%A4.md.

Give users a switch

GNU ls set a sensible convention: --hyperlink=auto emits links only when stdout is a terminal, while always and never force the choice. If your tool emits links at all, make auto the default.

Some tools go further. ripgrep treats NO_COLOR and TERM=dumb as a request for no escape sequences at all, links included. That is a reasonable default for anything that already respects color settings.

Close every link you open

An unclosed link makes everything printed after it clickable, sometimes including the shell prompt. Close links before writing error messages, on early returns, and in signal handlers. If your program can be interrupted mid-link, write \033]8;;\033\\ during cleanup. It is harmless when no link is open.

Tools that already emit OSC 8

Tool How to enable
GNU ls (coreutils 8.28 and later) ls --hyperlink=auto
ripgrep 14 and later rg --hyperlink-format=default, or an editor alias such as vscode
delta hyperlinks = true in the [delta] section of your Git config
GCC 10 and later -fdiagnostics-urls=auto links warning options to their documentation

macOS ships BSD ls, which has no --hyperlink option. brew install coreutils provides GNU ls as gls.

ripgrep is a good example of a careful implementation. It emits links only when it prints a path and colors are enabled, and it disables them when stdout is not a terminal. To see the raw bytes, force colors and make the escapes visible:

rg -H --color=always --hyperlink-format=default -n 'TODO' src/ | cat -v | head -3

Illustrative output, with the color codes trimmed:

^[]8;;file://build-host.local/home/alice/src/app/lib/utils.js^[\lib/utils.js:8^[]8;;^[\:// TODO: ...

^[ is ESC. The link opens before the path and closes after the line number. With --hyperlink-format=vscode, the target becomes vscode://file/home/alice/src/app/lib/utils.js:8:7, which opens the editor at that line and column instead of opening the file in its default application. ripgrep's format strings expose {host}, {path}, {line}, and {column} variables for other editors.

file:// links, hostnames, and SSH

A file:// URI names a file on a particular machine. The specification asks programs to include the hostname, as in file://build-host/home/alice/report.html, rather than file:///home/alice/report.html. The reason is SSH. A program running on a server prints links to the server's files, but the click is handled by the terminal on your laptop.

A careful terminal compares that hostname with its own. It should not open a local file that happens to have the same path as the remote one. What it does instead varies: refuse, show the URI, or, in a terminal that knows about the SSH session, open the remote file through that connection.

If you build tools, include the host using the same value gethostname() returns and let the terminal decide. ripgrep's {host} variable does this, and its --hostname-bin option covers environments where the system hostname is not the useful one.

Line numbers are the other gap. file:// URIs have no standard way to express a line and column, so editor-specific schemes fill it, as in the vscode://file/…:line:column form above. Only some terminals open custom schemes, and over SSH those paths still refer to the server.

Links inside tmux

A multiplexer sits between your program and the terminal and redraws the screen from its own cell grid. To pass links through, it has to understand OSC 8, store the link attribute, and emit it again. tmux 3.4 and later support this as the hyperlinks terminal feature.

If links work outside tmux but not inside, tell tmux the outer terminal supports them:

# ~/.tmux.conf
set -as terminal-features ',*:hyperlinks'

Replace * with a pattern matching your terminal's TERM value if you also attach from terminals without link support. Reload the configuration, then detach and reattach so the client picks up the feature. Older multiplexers that do not understand OSC 8 generally drop the sequence and show plain text, which is a safe failure. Our tmux and zellij guide covers the rest of a multiplexer setup.

Security: links you did not write

OSC 8 separates what you see from where a click goes. That is the feature, and also the risk.

Check the target before opening. Terminals generally show the URI on hover or in a status line. Text that reads https://github.com/your-org/... can point anywhere.

Do not print untrusted text raw. Any data containing ESC can carry an OSC 8 sequence. Log lines, HTTP headers, file names, commit messages, package metadata, and model output all qualify. When cat writes such data to a terminal, the terminal creates the link the data asked for. For quick inspection, cat -v and less without -R display escape sequences as visible text instead of interpreting them.

Treat custom schemes with care. Clicking a vscode:// link, or any other registered scheme, launches whichever application handles it. Terminals that allowlist schemes, or ask before opening unknown ones, are doing you a favor.

Coding agents print untrusted text too. An agent summarizing a web page or an issue thread may reproduce text from it, links included. Read the full URL before you click, especially in output produced from content someone else wrote.

If your tool prints data it did not produce, neutralize control characters first:

import re

_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")


def printable(untrusted: str) -> str:
    """Make control characters visible instead of letting the terminal act on them."""
    return _CONTROL.sub(lambda m: f"\\x{ord(m.group()):02x}", untrusted)


print(printable("build \x1b]8;;https://attacker.example\x1b\\ok\x1b]8;;\x1b\\"))
# build \x1b]8;;https://attacker.example\x1b\ok\x1b]8;;\x1b\

This keeps tabs and newlines and escapes everything else in the C0 and C1 control ranges. The C1 range (\x80 to \x9f) is included because some terminals act on C1 control characters, including a single-character form of OSC. Carriage return is escaped as well, since it lets text overwrite the start of a line and disguise what was printed.

Checklist for CLI authors

  • Emit links only when stdout is a terminal, and offer --hyperlink=auto|always|never.
  • Percent-encode URIs, and include the hostname in file:// URIs.
  • Close every link, including on error paths and interrupts.
  • Use id= for links you redraw or split across lines.
  • Never let untrusted input reach the terminal with raw control characters.
  • Inspect your output with cat -v or od -c, and check it in one terminal with OSC 8 support and one without.

In Agents UI

Agents UI 0.12.0 supports both kinds of links. Explicit OSC 8 hyperlinks work, and URLs printed in terminal output are detected, including ones coding agents wrap across lines. Command-click opens a link in the app's browser tab, and Command-Shift-click opens it in your default browser. For agent output, the URL shown on hover is still worth reading before you click.

Try Agents UI

A native terminal for AI coding agents with persistent sessions, SSH workflows, and built-in editing.