How Terminals Actually Work
A deep dive into the 60-year-old architecture that still powers every developer tool on your machine, told through the story of a bug that took hours to fix.
The Bug
I use Claude Code as my daily AI assistant. Each conversation runs in its own iTerm window, a split pane setup with a terminal on top and Claude on the bottom. When a new conversation starts, the window title says something generic like local | besowards | chat | claude. Once the conversation gets a name (a "slug" like iterm-title-slug-sync), Claude should update the window title to match.
Simple, right?
It took hours to solve. Not because the code was complex, but because the fix required understanding how terminals actually work: virtual teletypewriters, escape sequences, file descriptors, process I/O, and AppleScript bridging.
This is the story of that fix, and everything I learned along the way.
Part 1: What Is a Terminal?
When you open iTerm (or Terminal.app, or any terminal emulator), you're looking at a program pretending to be a 1960s teletypewriter.
The Original TTY
In the 1960s, computers didn't have screens. You interacted with them through a teletypewriter (TTY), a machine with a keyboard and a printer. You typed a command, the computer processed it, and the teletypewriter printed the response on paper. Character by character. No backspace. No colors. Just text on paper.
The interface was simple: characters go in, characters come out. Two directions, one channel each.
The Virtual TTY
Screens replaced printers. Then personal computers replaced mainframes. But the interface (characters in, characters out) was so clean and universal that it survived. Every terminal emulator today implements it.
When you open a new pane in iTerm, the operating system creates a virtual TTY, a pair of file descriptors that simulate the old teletypewriter connection:
Loading diagram...
The TTY has two ends:
- Master side: iTerm holds this. It sends your keystrokes in and reads program output out.
- Slave side: Your shell (zsh, bash) holds this. It reads input and writes output.
The slave side shows up as a file: /dev/ttys013. That file IS the terminal. Programs write to it, iTerm reads from it and renders the characters on screen.
You can see yours right now:
$ tty
/dev/ttys013
Every pane in iTerm gets its own TTY. Open 10 panes, you'll have 10 /dev/ttysXXX files.
Part 2: The Three Channels (stdin, stdout, stderr)
Every program that runs on your computer gets three I/O channels automatically:
| Channel | File Descriptor | Purpose |
|---|---|---|
| stdin | fd 0 | Where the program reads input |
| stdout | fd 1 | Where the program writes normal output |
| stderr | fd 2 | Where the program writes error messages |
When you run a command in your terminal, these channels are connected to the TTY by default:
Loading diagram...
echo "hello" writes "hello" to stdout (fd 1). Since stdout is connected to the TTY, iTerm receives it and displays it on screen.
Redirection Changes the Wiring
The shell lets you rewire these channels:
echo "hello" > file.txt # stdout goes to file.txt instead of the TTY
echo "hello" | grep "h" # stdout goes to a pipe, grep reads from the other end
When you redirect stdout to a file, the program has no idea. It writes to fd 1 the same way it always does. The operating system just changed where fd 1 points.
This is fundamental to understanding the bug.
Part 3: Escape Codes, the Terminal's Secret Language
Here's something that might surprise you: every time you see colored text in your terminal, bold text, a cursor moving, or a title changing... that's not special rendering. It's invisible characters in the output stream.
How Colors Work
When a program wants to print red text, it doesn't call a "set color to red" API. It writes invisible bytes into its stdout, mixed in with the visible text:
\033[31m H e l l o \033[0m
^^^^^^^^ ^^^^^^^^
invisible invisible
"start red" "reset color"
\033 is the ESC character (byte value 27). The terminal sees ESC[31m, recognizes it as "set text color to red," and starts rendering in red. The escape sequence itself is never displayed.
These are called ANSI escape codes, and they control everything:
\033[31m Set text color to red
\033[1m Bold
\033[H Move cursor to top-left
\033[2J Clear the screen
\033[?25l Hide the cursor
\033]0;TITLE\007 Set the window title
That last one is the one we care about.
The Title Escape Code
\033]0;My Window Title\007
│ │ │ │
│ │ │ └── BEL character (byte 7), marks the end
│ │ └──────────────── The title text
│ └────────────────── "0" means set both tab and window title
└────────────────────── ESC character (byte 27), starts the sequence
This is an "OSC" (Operating System Command) sequence. There are three variants:
| Code | What It Sets |
|---|---|
\033]0;TEXT\007 | Both tab title AND window title |
\033]1;TEXT\007 | Tab/icon title only |
\033]2;TEXT\007 | Window title only |
When iTerm reads these bytes from a TTY, it intercepts them (never displays them) and updates the window chrome.
The title() Function
Most developers who set terminal titles use a wrapper function. Here's the one from this story:
function title() {
echo -ne "\033]0;$1\007"
}
echo -ne writes the bytes without a trailing newline (-n) and interprets escape sequences (-e). The \033 becomes the actual ESC byte, \007 becomes the actual BEL byte. iTerm receives them through the TTY and changes the title.
It's one line of code, but it depends on a chain of assumptions:
- stdout is connected to a TTY
- The TTY is connected to a terminal emulator
- The terminal emulator processes OSC escape codes
Break any link in that chain, and the title doesn't change. The escape bytes either go nowhere or get displayed as garbage text.
Part 4: How Claude Code Runs Bash Commands
Claude Code is an AI assistant that runs in your terminal. When it needs to execute a shell command, it spawns a subprocess:
Loading diagram...
Claude captures the command's output by connecting stdout to a pipe, not to the TTY. This is how Claude reads what the command printed. But it means the command's stdout is no longer connected to iTerm.
You can verify this from inside Claude's bash:
$ tty
not a tty
The subprocess literally does not have a terminal. It has pipes.
Why This Breaks Title Setting
When Claude runs echo -ne "\033]0;my-slug\007":
- The escape bytes go to stdout (fd 1)
- Stdout is a pipe, not a TTY
- Claude reads the bytes from the pipe and captures them as text output
- iTerm never sees the escape codes
- The title doesn't change
The escape code works perfectly. It just goes to the wrong place.
Part 5: The Failed Attempts
Understanding why each approach failed reveals how the system actually works.
Attempt 1: Write to stderr
echo -ne "\033]0;slug\007" >&2
Theory: Maybe stderr is still connected to the TTY even if stdout isn't.
Reality: Claude's bash tool redirects stderr too. All three channels go to pipes. The escape code went to Claude's error capture, not to iTerm.
Attempt 2: AppleScript set name
tell session to set name to "slug"
Theory: Set the session name directly through iTerm's scripting interface.
Reality: The AppleScript name property and the displayed title are different things. The displayed title comes from the escape code history. Setting name changes an internal property that doesn't affect what you see in the tab bar.
Attempt 3: AppleScript write text to the terminal pane
tell terminal_pane to write text "title \"slug\""
Theory: Send the title command to the terminal pane (which has a real shell), changing its title.
Reality: This works for the terminal pane's title, but the tab/window title displays whichever pane has focus. Since the Claude pane has focus (the user is chatting with Claude), the tab still shows the Claude pane's old title.
Attempt 4: AppleScript write text to the Claude pane
tell claude_pane to write text "title \"slug\""
Theory: Send the title command to the Claude pane.
Reality: write text simulates keyboard input. The Claude pane is running Claude Code, not a shell. The text title "slug" gets sent to Claude as a user message. Claude reads it as if you typed it. The shell never sees it.
Attempt 5: Write to the TTY device file
echo -ne "\033]0;slug\007" > /dev/ttys013
Theory: Write directly to the TTY device file from outside.
First try: This appeared to fail. But the problem was subtler. Writing to /dev/ttysXXX does work, but only when do shell script (AppleScript) does it, not when Claude's piped bash does it.
Part 6: The Solution
The fix combines two insights:
Insight 1: Writing to a TTY device file from a real process
When a process writes to /dev/ttys013, the bytes go through the terminal's processing pipeline. iTerm receives them and processes escape codes. But the writing process needs to be a real process with file access, not a piped subprocess.
AppleScript's do shell script runs a command as an independent process (not inside any iTerm session). It has full file system access:
do shell script "printf '\\033]0;my-slug\\007' > /dev/ttys013"
This spawns a small process that opens /dev/ttys013 and writes the escape code bytes. iTerm, which is listening on the master side of that TTY, receives the bytes and processes the title change.
Insight 2: Target by UUID, not by "current window"
AppleScript's current window means "whichever window has focus." With 12 iTerm windows across 3 monitors, that's almost never the right one.
Instead, Claude's environment has ITERM_SESSION_ID, a UUID that uniquely identifies the session Claude is running in. The script walks every window, every tab, every session, looking for the one with the matching UUID. Then it writes to all sessions in that tab.
The Final Code
ITERM_UUID="${ITERM_SESSION_ID#*:}"
osascript -e "
tell application \"iTerm2\"
repeat with w in windows
repeat with t in tabs of w
repeat with s in sessions of t
if unique ID of s is \"$ITERM_UUID\" then
-- Found our session. Write title to ALL panes in this tab.
repeat with p in sessions of t
set ttyPath to tty of p
do shell script \"printf '\\\\033]0;my-slug\\\\007' > \" & ttyPath
end repeat
return
end if
end repeat
end repeat
end repeat
end tell
"
Step by step:
- Extract the UUID from
ITERM_SESSION_ID(format isw0t0p0:UUID) - Walk all windows, tabs, and sessions looking for a matching UUID
- When found, iterate all sessions in that tab (both panes)
- For each pane, get its TTY path (e.g.,
/dev/ttys013) - Use
do shell scriptto write the title escape code to that TTY - iTerm processes the escape code and updates the title
We write to ALL panes because iTerm's tab/window title follows whichever pane has focus. If both panes have the same title set, the title is correct regardless of where the user clicks.
Part 7: The Full Data Flow
Here's what happens end-to-end when Claude names a conversation:
Loading diagram...
The whole thing takes milliseconds. The user sees the window title change instantly.
Part 8: Why This Architecture Exists
You might wonder: why is this so complicated? Why can't a program just call setWindowTitle("my title")?
The answer is separation of concerns, and it goes back to the 1960s design.
Programs Don't Know About Terminals
A program writes bytes to stdout. That's it. It doesn't know if stdout goes to a terminal, a file, a pipe, another program, or a printer. This is deliberate:
ls -la # Output goes to terminal (you see it)
ls -la > files.txt # Output goes to a file (you don't see it)
ls -la | grep ".md" # Output goes to another program
ls runs the same code in all three cases. It doesn't care where its output goes. This is what makes Unix pipes and redirection so powerful. Programs are composable because they don't make assumptions about their environment.
The Terminal Is Just Another Program
iTerm isn't special. It's a program that:
- Creates a virtual TTY (pair of file descriptors)
- Launches your shell connected to the slave side
- Reads from the master side and renders characters on screen
- Takes keyboard input and writes to the master side
Any program can create a TTY and do this. That's how tmux, screen, SSH, and Docker work. They're all terminal emulators of one kind or another, nesting TTYs inside TTYs.
Escape Codes Are In-Band Signaling
The title-setting mechanism is "in-band." The control commands travel through the same channel as the data. There's no separate "control channel" for metadata like titles. This is both elegant (no extra infrastructure needed) and fragile (redirect the output and you lose the controls).
Modern terminal emulators like iTerm add proprietary escape codes for features the 1960s designers never imagined (inline images, clickable links, notifications, profile switching) but they all flow through the same character stream.
The Lesson
The terminal is one of the oldest abstractions in computing, and it's still everywhere. Every Docker container, every SSH session, every CI/CD pipeline, every IDE terminal panel: they all use virtual TTYs, escape codes, and the stdin/stdout/stderr model designed for paper teletypewriters in the 1960s.
Most of the time, you don't need to think about it. The abstraction just works. But when it doesn't (when you need to reach across process boundaries, inject escape codes into foreign sessions, or understand why your output looks like garbage) there's no shortcut. You have to understand the plumbing.
The bug took hours to fix not because the solution was complex (it's 15 lines of AppleScript) but because finding it required understanding six decades of computing history that most tutorials skip over.
Now you know where the bytes go.