- Book: AI Agents Pocket Guide
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You set FORCE_HYPERLINK=false because you wanted plain URLs in your terminal. Claude Code read it and switched clickable links on.
That's not a guess. The Claude Code 2.1.314 changelog has the line "Changed FORCE_HYPERLINK=false (also no and off) to turn hyperlinks off instead of on". The environment variables reference now says to set 0, false, no or off to render links as plain text, and adds: "Before v2.1.314, false, no, and off enabled hyperlinks instead."
The word "false" most likely meant yes because of one line of JavaScript that many Node CLIs share for this variable. Behind that line sits a terminal feature most developers use every day without knowing its name: OSC 8.
Why "false" meant yes
FORCE_HYPERLINK isn't a Claude Code invention. It's the override honoured by supports-hyperlinks, the small package from the chalk family that Node CLIs use to decide whether to print clickable links. Its README says FORCE_HYPERLINK=1 forces links on, FORCE_HYPERLINK=0 forces them off, and the variable "overrides all other hyperlink support checks."
Here is the check, from its index.js:
if (FORCE_HYPERLINK) {
return !(
FORCE_HYPERLINK.length > 0 &&
Number.parseInt(FORCE_HYPERLINK, 10) === 0
);
}
Read it slowly. The variable is set, so you're inside the branch. The function returns "on" unless the value parses to the integer 0. Number.parseInt('false', 10) is NaN, and NaN === 0 is false, so the whole expression becomes !(true && false), which is true. The same goes for no, off, disabled and never. Only a value that parseInt reads as 0 turns links off.
Anthropic doesn't publish the Claude Code CLI's source, so I can't show you the line inside Claude Code. But the behaviour the docs describe before 2.1.314 matches this rule value for value: 0 off, false, no and off on.
Two side effects fall out of parseInt. It reads digits until it hits something else, so 0abc disables links, and so does 0x1, because with radix 10 it stops at the x and returns 0. The package knows this. A lint comment right above the line explains why it keeps parseInt over Number(): "Number() requires the whole string, so FORCE_HYPERLINK=0abc would go from disabled to enabled." Switching to Number() wouldn't have fixed false anyway. Number('false') is NaN too.
The rule was written for numbers. People typed words.
OSC 8, byte by byte
A clickable link in a terminal is text wrapped in two escape sequences. The de facto spec is a GitHub gist on hyperlinks in terminal emulators that terminal authors have pointed to for years. Run this in your terminal:
printf '\033]8;;https://example.com\033\\Example\033]8;;\033\\\n'
If your terminal supports OSC 8, you see the word Example, and Cmd-click or Ctrl-click opens example.com. If it doesn't, you see Example as plain text with no junk around it. That's the design: the spec says a terminal that parses OSC sequences correctly ignores ones it doesn't know.
Here is every piece of that line:
| Bytes | Hex | What it does |
|---|---|---|
ESC ] |
1B 5D |
OSC, "operating system command" |
8 |
38 |
OSC number 8: hyperlink |
; |
3B |
Separator |
| (empty) | Parameters, key=value pairs joined by :
|
|
; |
3B |
Separator |
https://example.com |
The URI, bytes 32 to 126 only | |
ESC \ |
1B 5C |
ST, the string terminator |
Example |
The visible text, now part of the link | |
ESC ] 8 ; ; ESC \ |
Same sequence with no URI: closes the link |
\033 is the octal escape for ESC. The spec's own example writes it as \e, which bash's printf accepts but some other shells' printf doesn't, so \033 is the safer spelling. The doubled backslash is printf syntax for a single \. Pipe the command into od -c and you can see each byte yourself.

OSC 8: an opening sequence, the visible text, a closing sequence.
A few rules from the spec matter in practice. The terminator can also be BEL (\a), a habit inherited from XTerm, but the spec prefers ESC \. The only defined parameter is id: cells with the same URI and the same id underline together on hover, which full-screen apps need when a link wraps or gets repainted. VTE and iTerm2 cap the URI at 2083 bytes. And everything after the opening sequence belongs to the link until you close it, so forgetting the closing sequence turns the rest of your output into one long link.
printf '\033]8;id=docs;https://example.com\033\\docs\033]8;;\033\\\n'
Who decides the text is clickable
Two programs make two separate decisions.
The first is the program printing the link. The spec is blunt: "Currently there's no way of detecting whether the terminal emulator supports hyperlinks." A CLI can't ask the terminal. So it guesses from the environment, which is exactly what supports-hyperlinks does, roughly in this order: FORCE_HYPERLINK first, then --no-hyperlink and --hyperlink=always flags, then "is this stream a TTY with colour support at all." WT_SESSION means Windows Terminal, so yes. CI or TEAMCITY_VERSION means no. After that it reads TERM_PROGRAM and its version: iTerm.app from 3.1, vscode from 1.72, ghostty, zed, WarpTerminal and WezTerm among others. VTE terminals qualify from 0.50, with 0.50.0 itself excluded because it segfaults, and kitty and Alacritty get in through TERM.
A terminal that isn't on the library's list gets plain text, even if it renders OSC 8 perfectly. Claude Code's docs describe the same gap for FORCE_HYPERLINK=1: "when your terminal supports them but isn't auto-detected."
The second decision belongs to the terminal. iTerm2's escape code docs say: "To open a link, hold Command and click the link." xterm.js, the web-based terminal component that desktop apps embed, parses OSC 8 itself and hands clicks to a linkHandler the app provides. If the app provides none, its default asks "Do you want to navigate to" the URI, with a warning that the link "could potentially be dangerous," and it drops anything that isn't http: or https: unless the handler sets allowNonHttpProtocols.
xterm.js also separates OSC 8 links from what its docs call implicit links: plain text that looks like a URL, found by pattern matching through the web-links addon. Those are the links you get when a program prints a bare URL. OSC 8 links are the ones where the visible text and the target can differ.
The truthy-string bug is everywhere
Every environment variable is a string. There is no boolean. Each program decides what a string means, and they don't agree.
supports-color, the sibling package in the same family, special-cases words: FORCE_COLOR=true returns level 1, FORCE_COLOR=false returns level 0, digits are read as a level up to 3, and other strings are ignored. supports-hyperlinks treats false as on. Same family, two answers to the same question.
NO_COLOR goes the other way on purpose. Colour is off when the variable is "present and not an empty string (regardless of its value)." So NO_COLOR=0 and NO_COLOR=false both turn colour off. That's the convention, and it's written down, which is the point.
Claude Code's docs show the careful version for DEBUG: "Only the truthy values 1, true, yes, and on enable debug mode, so namespace patterns like DEBUG=express:* set for other tools do not trigger it." Since 2.1.314, FORCE_HYPERLINK gets the same treatment in reverse.

An environment variable is always a string. The program decides what "off" means.
If you write a CLI, parse flags into three states, not two, and fall back to detection on anything you don't recognise:
function envFlag(value) {
if (value === undefined) return undefined;
const v = value.trim().toLowerCase();
if (v === '') return undefined;
if (['1', 'true', 'yes', 'on'].includes(v)) return true;
if (['0', 'false', 'no', 'off'].includes(v)) return false;
return undefined; // unknown word: auto-detect instead
}
The shell version is a case:
case "${FORCE_HYPERLINK:-}" in
1|true|yes|on) links=on ;;
0|false|no|off) links=off ;;
*) links=auto ;;
esac
Then document the words you accept. The visible half of the Claude Code fix is a docs row that lists four words.
Why the escape codes an agent prints matter
An OSC 8 link shows one string and opens another. In your own build script's output that's a convenience. In the output of a coding agent that just read a README, an issue thread or a web page you didn't write, it's a link whose text and target came from somewhere else. The spec's own security notes suggest showing the URI before opening and opening only on a modified click, and the xterm.js default dialog does the first.
FORCE_HYPERLINK=1 turns Claude Code's links on in a terminal it doesn't detect. FORCE_HYPERLINK=0, and since 2.1.314 also false, no or off, renders them as plain text. Claude Code is on 2.1.318 at the time of writing, so update if you set one of the words and wondered why nothing changed.
I build Hermes IDE, so weigh this part with that in mind. Every agent in Hermes runs in a real terminal by default, and its README lists clickable web links in those terminals. They're built on xterm.js 6 with the web-links addon, as the public package.json shows. xterm.js parses OSC 8 itself, but I haven't checked how Hermes handles those clicks, or whether Claude Code auto-detects the Hermes terminal. If you want to try, FORCE_HYPERLINK=1 is the documented switch. If you'd rather have plain text, FORCE_HYPERLINK=0 is.
If this was useful
Hermes IDE is free to download for macOS, Windows and Linux, source-available under BSL 1.1, and built in the open on GitHub. It isn't affiliated with Anthropic, OpenAI, Nous Research or any agent vendor.
What an agent prints is part of what it does, and a link whose text and target came from a page you didn't write is exactly the kind of output a guardrail should catch. The AI Agents Pocket Guide covers the patterns behind agent loops, tool calls and the guardrails around them, so you can decide what an agent is allowed to do and what reaches you unchecked.

Top comments (0)