Out-of-Band Hook Notifications via terminalSequence¶
The
terminalSequencefield on hook JSON output lets a hook ping the human — desktop notification, window title, bell — without writing to the agent's transcript and without depending onosascript,notify-send, or a writable/dev/tty.
The channel¶
Claude Code v2.1.141 (May 13, 2026) added terminalSequence to hook JSON output. When a hook returns
{ "terminalSequence": "\u001b]777;notify;Claude Code;Build finished" }
the harness writes those bytes to its own controlling terminal after parsing the hook's JSON. The bytes never enter the agent's context window. The human sees a notification; the agent sees nothing.
This replaces three legacy patterns:
| Legacy pattern | Failure mode |
|---|---|
Hook writes to /dev/tty directly |
Removed from hook processes in v2.1.139; never worked on Windows |
| Hook prints to stdout | Appended to agent context — spends tokens, re-enters the reasoning loop |
Hook shells out to osascript / notify-send / BurntToast |
Platform-specific; silently fails when the notifier lacks permission (hooks guide); requires per-OS branches |
The structured field works inside tmux, over ssh, on Windows, and from any language that can emit JSON.
The allowlist¶
The field accepts a string of escape sequences from a fixed set (hooks reference). Anything outside this set is rejected and the field is silently ignored:
| Sequence | Effect | Honored by |
|---|---|---|
OSC 0 / OSC 1 / OSC 2 |
Window and icon title | All major emulators |
OSC 9 |
Desktop notification (and 9;4 taskbar progress) |
iTerm2, ConEmu, Windows Terminal, WezTerm |
OSC 99 |
Desktop notification | Kitty |
OSC 777 |
Desktop notification | urxvt, Ghostty, Warp |
Bare BEL |
Audible bell | All terminals |
Sequences may terminate with BEL (\007) or ST (\\). Rejected sequences include CSI cursor and color codes, OSC 8 hyperlinks, OSC 52 clipboard writes, and OSC 1337.
The allowlist is a security control. By excluding cursor-move, color, clipboard, and hyperlink sequences, a hook cannot corrupt the on-screen prompt, smuggle a hidden link, or write to the system clipboard (hooks reference).
Where it belongs¶
Hook events that fire when the human is the next actor justify a notification. Hook events that fire on every tool call do not.
| Event | Notify? | Reason |
|---|---|---|
Stop |
Yes | Agent finished; the human resumes |
SubagentStop |
Yes | Long sub-task finished; signal completion |
Notification (permission prompt, idle) |
Yes | Agent is blocked waiting on the human |
StopFailure |
Yes | API error; human action likely required — see StopFailure Hook |
PreToolUse / PostToolUse |
No | Fires dozens of times per task — notification fatigue |
SessionStart / PreCompact |
No | Routine; the human is not waiting |
Example¶
A Stop hook that pings the desktop and updates the window title with the completion status.
.claude/hooks/notify-stop.sh:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
# OSC 777 notification (urxvt/Ghostty/Warp), OSC 9 (iTerm2/Windows Terminal/WezTerm),
# OSC 2 window title. Terminals ignore sequences they don't recognise.
NOTIFY=$(printf '\033]777;notify;Claude Code;Turn finished (%s)\007' "$SESSION_ID")
ITERM=$(printf '\033]9;Claude Code: turn finished\007')
TITLE=$(printf '\033]2;Claude: done\007')
jq -nc \
--arg seq "${NOTIFY}${ITERM}${TITLE}" \
'{terminalSequence: $seq}'
.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/notify-stop.sh" }
]
}
]
}
}
The same hook works on macOS iTerm2, Windows Terminal, a Linux Ghostty, and tmux over ssh into a remote dev box. The agent's transcript is unchanged.
Why it works¶
Two properties carry the load. The bytes leave the JSON parser and go straight to the terminal Claude Code already owns — race-free, working inside tmux/screen and on Windows where /dev/tty does not exist (hooks reference). The sequence also never appears in the model's transcript: a Stop notification adds zero tokens to the next turn, unlike stdout output that is treated as additionalContext and re-enters the reasoning loop. The OSC mechanism itself is decades-old; the new part is the structured channel — the harness, not the hook, owns the tty write.
When it backfires¶
- Headless or CI runs —
claude --headlesshas no terminal user; the sequence clutters captured logs and produces no notification. Gate on$CLAUDE_HEADLESSor skip on non-interactive sessions. - Unsupported terminal — bare
xterm, basic Linux console, or piping to a file emits the sequence but produces no notification, with no acknowledgment to distinguish delivered from silently dropped. - Notification fatigue — wiring to high-frequency events (
PostToolUse,PreCompact) produces dozens of bells per task. Restrict to events where the human is the next actor. - DnD and Focus modes are terminal-specific — OSC 9 in iTerm2 routes through Notification Center and honors DnD; OSC 777 in urxvt or Ghostty may not.
- Composed writes are not transactional — a title-then-notification-then-bell sequence interrupted mid-write leaves the title set. Order most important first.
- Regression risk in the path itself —
claude-code#58909reportsNotification:permission_promptstopping during active thinking in 2.1.141. Smoke-test the hook on each minor version.
Key Takeaways¶
terminalSequenceis a structured hook output field that emits allowlisted terminal escape sequences via the harness's own write path- The allowlist (OSC 0/1/2, OSC 9, OSC 99, OSC 777, BEL) is a security control — cursor, color, clipboard, and hyperlink sequences are rejected
- The notification never enters the agent's transcript — zero token cost, no re-entry into the reasoning loop
- Restrict to
Stop,SubagentStop,Notification, andStopFailure; high-frequency events produce fatigue - Always fail silent: a terminal that does not honor the sequence emits no error and must not block the agent