Claude Code has two completely separate notification pipelines that serve different purposes and live in different layers of the codebase:
In-REPL Toast Queue
Status messages shown in the footer bar of the terminal UI. Managed by context/notifications.tsx and rendered by Notifications.tsx.
OS / Terminal Notifier
Native desktop/terminal notifications (iTerm2, Kitty, Ghostty, bell). Managed by services/notifier.ts and ink/useTerminalNotification.ts.
context/notifications.tsx →
components/PromptInput/Notifications.tsx →
hooks/notifs/*.tsx →
services/notifier.ts →
ink/useTerminalNotification.ts →
utils/collapseBackgroundBashNotifications.ts
These pipelines never cross: the toast queue only modifies React state and renders in the Ink UI; the terminal notifier writes raw OSC/BEL escape sequences directly to the TTY. Understanding both is essential for diagnosing why a notification appears (or doesn't) in any given environment.
The Notification Type
Every toast is a Notification union type defined at the top of
context/notifications.tsx. It can be plain text or arbitrary JSX:
// context/notifications.tsx
type Priority = 'low' | 'medium' | 'high' | 'immediate'
type BaseNotification = {
key: string
invalidates?: string[] // keys of notifs this one cancels
priority: Priority
timeoutMs?: number // default 8000 ms
fold?: (accumulator: Notification, incoming: Notification) => Notification
}
type TextNotification = BaseNotification & { text: string; color?: keyof Theme }
type JSXNotification = BaseNotification & { jsx: React.ReactNode }
export type Notification = TextNotification | JSXNotification
The fold field is especially interesting — it behaves like
Array.reduce(). When a second notification with the same
key arrives while the first is still displaying or queued,
fold(accumulator, incoming) merges them in place rather than
creating a duplicate entry.
Priority Ordering
The getNext() function exported from context/notifications.tsx
implements priority-based dequeue. The queue is not FIFO — it always promotes the
highest-priority item:
const PRIORITIES: Record<Priority, number> = {
immediate: 0,
high: 1,
medium: 2,
low: 3,
}
export function getNext(queue: Notification[]): Notification | undefined {
if (queue.length === 0) return undefined
return queue.reduce((min, n) =>
PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min
)
}
| Priority | Rank | Behavior | Used by |
|---|---|---|---|
| immediate | 0 | Preempts whatever is currently showing; bumped item re-queues (non-immediate only) | Rate limit reached, overage mode entered, external editor hint |
| high | 1 | Queued, wins over medium/low at dequeue | Rate limit warning, model deprecation |
| medium | 2 | Queued, beats low | LSP errors, env-hook errors |
| low | 3 | Queued, shown last | Env-hook success messages (5 s timeout) |
The State Shape
Notifications live inside the central AppState as two fields — a
single current slot plus an unbounded queue:
// AppState.notifications shape
{
current: Notification | null,
queue: Notification[]
}
The useNotifications() hook exposes addNotification and
removeNotification. A module-level currentTimeoutId
tracks the running auto-dismiss timer — a deliberate module singleton rather
than React state, so it can be cleared synchronously when an
immediate notification preempts the display.
addNotification: The Full Decision Tree
processQueue: Advancing the Display
processQueue() is called after every mutation. It only promotes a
queued item when current === null. When it promotes, it schedules
an auto-dismiss timeout after which it nulls out current and calls
processQueue() again — a simple recursive pump:
const processQueue = useCallback(() => {
setAppState(prev => {
const next = getNext(prev.notifications.queue)
// Only advance if nothing is currently showing
if (prev.notifications.current !== null || !next) return prev
currentTimeoutId = setTimeout(
(setAppState, nextKey, processQueue) => {
currentTimeoutId = null
setAppState(prev => {
// Key comparison guards against stale closures
if (prev.notifications.current?.key !== nextKey) return prev
return { ...prev, notifications: { queue: prev.notifications.queue, current: null } }
})
processQueue()
},
next.timeoutMs ?? DEFAULT_TIMEOUT_MS,
setAppState, next.key, processQueue
)
return {
...prev,
notifications: {
queue: prev.notifications.queue.filter(_ => _ !== next),
current: next,
}
}
})
}, [setAppState])
setTimeout callback receives setAppState, nextKey,
and processQueue as extra arguments rather than closing over them. This avoids
stale-closure bugs when the component re-renders between the timer firing and the callback
running. The key comparison (current?.key !== nextKey) is a second guard for
the same class of bug.
components/PromptInput/Notifications.tsx is the orchestration hub.
It sits in the footer of the REPL prompt area. Its job is twofold:
- Run all the
use*Notificationhooks so they register their effects. - Render the non-toast status indicators (IDE selection, token warning, MCP status, voice, updater).
The current notification from AppState is read with:
const notifications = useAppState(_temp) // selector: s => s.notifications
The component also wires the env-hook notifier — a bridge from the file-changed watcher service into the React notification system:
useEffect(() => {
setEnvHookNotifier((text, isError) => {
addNotification({
key: "env-hook",
text,
color: isError ? "error" : undefined,
priority: isError ? "medium" : "low",
timeoutMs: isError ? 8000 : 5000,
})
})
}, [addNotification])
And it handles the external editor hint — a short-lived immediate toast
shown whenever the prompt is wrapped and the user has an external editor configured:
if (shouldShowExternalEditorHint && editor) {
addNotification({
key: "external-editor-hint",
jsx: <ConfigurableShortcutHint action="chat:externalEditor" ... />,
priority: "immediate",
timeoutMs: 5000,
})
} else {
removeNotification("external-editor-hint")
}
The hooks/notifs/ directory contains 14 hooks, each responsible for
exactly one notification concern. They all follow the same pattern: import
useNotifications(), watch some state in a useEffect,
call addNotification() when a condition triggers.
useStartupNotification — The DRY Base
Most startup hooks share boilerplate: skip in remote mode, fire once per session,
handle async, log errors. useStartupNotification encapsulates this:
// hooks/notifs/useStartupNotification.ts
export function useStartupNotification(
compute: () => Result | Promise<Result>
): void {
const { addNotification } = useNotifications()
const hasRunRef = useRef(false)
const computeRef = useRef(compute)
computeRef.current = compute
useEffect(() => {
if (getIsRemoteMode() || hasRunRef.current) return
hasRunRef.current = true
void Promise.resolve()
.then(() => computeRef.current())
.then(result => {
if (!result) return
for (const n of Array.isArray(result) ? result : [result]) {
addNotification(n)
}
})
.catch(logError)
}, [addNotification])
}
computeRef pattern captures the latest compute function
without making it a dependency of the effect. This means the effect fires exactly once
on mount but always calls the current compute function — avoiding both stale closures
and re-firing on every render.
Full Hook Inventory
| Hook file | Notification key | Priority | Trigger |
|---|---|---|---|
useRateLimitWarningNotification |
rate-limit-warning |
high | Approaching Claude.ai usage limit for current model |
useRateLimitWarningNotification |
limit-reached |
immediate | Entered overage mode; fires once per overage entry |
useDeprecationWarningNotification |
model-deprecation-warning |
high | Selected model has a deprecation warning string |
useLspInitializationNotification |
lsp-error-{source} |
medium | LSP manager init failure or server enters error state (polled every 5 s) |
useFastModeNotification |
fast-mode |
high | Model switches to fast/turbo mode |
useAutoModeUnavailableNotification |
auto-mode-unavailable |
high | Auto model selection unavailable for current subscription |
useModelMigrationNotifications |
model-migration |
high | Active model has been migrated/renamed |
useNpmDeprecationNotification |
npm-deprecation |
high | npm package used by active plugin is deprecated |
usePluginAutoupdateNotification |
plugin-autoupdate |
low | Plugin successfully auto-updated in background |
usePluginInstallationStatus |
plugin-install-{name} |
medium | Plugin install success or failure |
useMcpConnectivityStatus |
mcp-connectivity |
medium | MCP server connection lost or regained |
useIDEStatusIndicator |
ide-status |
medium | IDE connection changes (not a toast — renders inline) |
useInstallMessages |
install-msg-* |
low | Post-update install notes from release manifest |
useTeammateShutdownNotification |
teammate-shutdown |
high | Companion agent process exited unexpectedly |
useSettingsErrors |
settings-error-* |
high | Settings validation errors on startup |
Notifications.tsx inline |
external-editor-hint |
immediate | Prompt is wrapped + editor configured; removed when conditions clear |
Notifications.tsx inline |
env-hook |
medium / low | File-changed watcher hook fires (error = medium, info = low) |
Worked Example: Rate Limit Warning
useRateLimitWarningNotification uses useRef to debounce the
warning — it only fires once per unique warning string (not on every render):
// hooks/notifs/useRateLimitWarningNotification.tsx
const shownWarningRef = useRef<string | null>(null)
useEffect(() => {
if (getIsRemoteMode()) return
if (rateLimitWarning && rateLimitWarning !== shownWarningRef.current) {
shownWarningRef.current = rateLimitWarning
addNotification({
key: 'rate-limit-warning',
jsx: <Text><Text color="warning">{rateLimitWarning}</Text></Text>,
priority: 'high',
})
}
}, [rateLimitWarning, addNotification])
// Separate effect: when overage actually kicks in, immediate preempt
useEffect(() => {
if (claudeAiLimits.isUsingOverage && !hasShownOverageNotification) {
addNotification({
key: 'limit-reached',
text: usingOverageText,
priority: 'immediate',
})
setHasShownOverageNotification(true)
}
}, [claudeAiLimits.isUsingOverage, ...])
When Claude finishes a long-running task and the window is backgrounded, the
in-REPL toast is invisible. That's where services/notifier.ts comes in:
it routes notifications to the terminal's native OS notification API.
sendNotification: The Entry Point
// services/notifier.ts
export type NotificationOptions = {
message: string
title?: string
notificationType: string
}
export async function sendNotification(
notif: NotificationOptions,
terminal: TerminalNotification,
): Promise<void> {
const config = getGlobalConfig()
const channel = config.preferredNotifChannel
await executeNotificationHooks(notif) // user hooks first
const methodUsed = await sendToChannel(channel, notif, terminal)
logEvent('tengu_notification_method_used', {
configured_channel: channel,
method_used: methodUsed,
term: env.terminal,
})
}
The hook system runs before the built-in channel dispatch. This lets users intercept notifications and route them to Slack, Discord, or any other system without modifying the core code.
Channel Routing
config.preferredNotifChannel maps to one of these dispatch branches:
switch (channel) {
case 'auto': return sendAuto(opts, terminal)
case 'iterm2': terminal.notifyITerm2(opts); return 'iterm2'
case 'iterm2_with_bell': terminal.notifyITerm2(opts); terminal.notifyBell(); return 'iterm2_with_bell'
case 'kitty': terminal.notifyKitty({ ...opts, id: generateKittyId() }); return 'kitty'
case 'ghostty': terminal.notifyGhostty(opts); return 'ghostty'
case 'terminal_bell': terminal.notifyBell(); return 'terminal_bell'
case 'notifications_disabled': return 'disabled'
}
The auto branch detects the terminal from env.terminal and
picks the best available method. For Apple Terminal it even reads the
com.apple.Terminal plist via osascript to check whether
the bell is disabled before falling back:
case 'Apple_Terminal': {
const bellDisabled = await isAppleTerminalBellDisabled()
return bellDisabled
? (terminal.notifyBell(), 'terminal_bell')
: 'no_method_available'
}
case 'iTerm.app': terminal.notifyITerm2(opts); return 'iterm2'
case 'kitty': terminal.notifyKitty(...); return 'kitty'
case 'ghostty': terminal.notifyGhostty(...); return 'ghostty'
useTerminalNotification — OSC Escape Sequences
The actual wire protocol lives in ink/useTerminalNotification.ts.
Each terminal has its own notification escape sequence standard:
// iTerm2: OSC 9 sequence
notifyITerm2({ message, title }) {
const display = title ? `${title}:\n\n${message}` : message
writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, `\n\n${display}`)))
}
// Kitty: three-step OSC 99 sequence (title, body, focus)
notifyKitty({ message, title, id }) {
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=0:p=title`, title)))
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:p=body`, message)))
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=1:a=focus`, '')))
}
// Ghostty: single OSC sequence
notifyGhostty({ message, title }) {
writeRaw(wrapForMultiplexer(osc(OSC.GHOSTTY, 'notify', title, message)))
}
// Universal fallback: raw BEL character (0x07)
// NOT wrapped — tmux needs bare BEL to trigger bell-action
notifyBell() { writeRaw(BEL) }
bell-action (window flag),
which is the desired cross-terminal notification fallback.
Progress Reporting
The terminal notification hook also exposes a progress() method that
sends OSC 9;4 task-progress sequences — supported by ConEmu, Ghostty 1.2.0+, and
iTerm2 3.6.6+. This drives the progress indicator in the terminal's tab/taskbar:
terminal.progress('running', 42) // 42% progress bar
terminal.progress('indeterminate') // spinning indicator
terminal.progress('error', 80) // error state at 80%
terminal.progress('completed') // clears the indicator
terminal.progress(null) // explicit clear
A third, separate notification mechanism handles background bash completions.
These are not toasts — they are user role messages injected into
the conversation with a <task_notification> XML tag.
utils/collapseBackgroundBashNotifications.ts post-processes the
message list before rendering. When multiple consecutive completed background
bash tasks appear, they collapse into a single synthetic message:
// utils/collapseBackgroundBashNotifications.ts
export function collapseBackgroundBashNotifications(
messages: RenderableMessage[],
verbose: boolean,
): RenderableMessage[] {
if (!isFullscreenEnvEnabled()) return messages
if (verbose) return messages // ctrl+O shows each individually
// Scan for runs of completed bash tasks
while (isCompletedBackgroundBash(messages[i])) {
count++; i++;
}
if (count > 1) {
// Synthesize: "<task_notification>...N background commands completed...</task_notification>"
result.push(syntheticCollapsedMessage(count))
}
}
Only successful completions are collapsed (status tag = completed).
Failed and killed tasks always remain as individual messages. Monitor-kind
completions have their own summary prefix and are also excluded.
Verbose mode (ctrl+O) bypasses collapsing entirely so you can
audit each completion.
A fourth notification pathway exists for feature-flagged integrations.
services/mcp/channelNotification.ts implements the
notifications/claude/channel MCP extension — it lets external
channels (Discord, Slack, iMessage via MCP server) push inbound messages
into the conversation.
// The MCP server declares this capability to opt in:
capabilities.experimental['claude/channel']: {}
// Messages arrive via:
{
method: 'notifications/claude/channel',
params: {
content: "user message text",
meta: { chat_id: "123", user: "alice" } // arbitrary attrs
}
}
The handler wraps the content in a <channel> XML tag and
enqueues it. SleepTool polls hasCommandsInQueue() every second
and wakes the agent. The model sees the source and decides which MCP tool
to call for the reply.
Channel notifications are gated by a 6-layer security check in
gateChannelServer():
Capability
Server must declare experimental['claude/channel']
Runtime flag
isChannelsEnabled() — GrowthBook kill-switch
Auth
Requires Claude.ai OAuth token (API key users blocked)
Org policy
Team/Enterprise orgs must set channelsEnabled: true
Session opt-in
Server must be in --channels flag for this session
Allowlist
Plugin marketplace verification + approved-plugin ledger check
Key Takeaways
- Two pipelines serve different needs: the toast queue for interactive status; the OS notifier for backgrounded sessions.
- The queue is priority-based (immediate/high/medium/low), not FIFO.
getNext()does a linear reduce on priority rank. immediatenotifications preempt the current display and bump the displaced item back into the queue (if non-immediate).- The
foldfield lets same-key notifications merge likeArray.reduce()— timeout is reset on each fold. - A module-level
currentTimeoutId(not React state) enables synchronous timeout cancellation when immediate notifications arrive. useStartupNotificationabstracts the remote-mode gate and once-per-session ref guard that every startup hook needs.- The OS notifier dispatches to iTerm2 / Kitty / Ghostty / BEL depending on
config.preferredNotifChannelandenv.terminal. BEL is deliberately unwrapped so tmux handles it natively. - Background bash completions are collapsed at the message-list level — not a React notification — to keep the conversation clean without losing failure details.
- MCP channel notifications (Kairos) pass through a 6-layer security gate before the handler is registered.
Knowledge Check
immediate notification arrives while a high notification is currently displaying. What happens to the high notification?fold field on BaseNotification?0x07) written to the TTY without the multiplexer wrapper, while iTerm2 OSC sequences are wrapped?collapseBackgroundBashNotifications only collapses background bash completions in one condition — when verbose is false. What else must be true?gateChannelServer()?