markdown.engineering
Lesson 30

The Notification System

Two distinct pipelines — an in-REPL toaster queue and an OS-level terminal notifier — plus a full catalog of every hook that feeds them.

01 Overview

Claude Code has two completely separate notification pipelines that serve different purposes and live in different layers of the codebase:

Pipeline 1

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.

Pipeline 2

OS / Terminal Notifier

Native desktop/terminal notifications (iTerm2, Kitty, Ghostty, bell). Managed by services/notifier.ts and ink/useTerminalNotification.ts.

Source files covered
context/notifications.tsxcomponents/PromptInput/Notifications.tsxhooks/notifs/*.tsxservices/notifier.tsink/useTerminalNotification.tsutils/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.

02 Pipeline 1 — The In-REPL Toast Queue

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

flowchart TD A["addNotification(notif)"] --> B{priority\n=== 'immediate'?} B -->|Yes| C["clearTimeout(currentTimeoutId)"] C --> D["Set new timeout for this notif\n(timeoutMs ?? 8000 ms)"] D --> E["setAppState: current = notif\nqueue = [prev current if not immediate,\n...prev queue]\n.filter(not immediate,\nnot in invalidates)"] E --> F["return early"] B -->|No| G{notif.fold\ndefined?} G -->|Yes| H{current.key\n=== notif.key?} H -->|Yes| I["fold(current, notif)\nreset timeout\nreturn folded as current"] H -->|No| J{queue has\nsame key?} J -->|Yes| K["fold(queue[i], notif)\nreplace in queue"] J -->|No| L{key already\nin queue\nor current?} G -->|No| L L -->|Yes, duplicate| M["return prev (no-op)"] L -->|No| N{notif.invalidates\ncontains current.key?} N -->|Yes| O["clearTimeout\ncurrent = null"] N -->|No| P["Keep current as-is"] O --> Q["append notif to queue\n(filtered of invalidated)"] P --> Q Q --> R["processQueue()"]

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])
Implementation detail
The 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.
03 The Notifications Render Component

components/PromptInput/Notifications.tsx is the orchestration hub. It sits in the footer of the REPL prompt area. Its job is twofold:

  1. Run all the use*Notification hooks so they register their effects.
  2. 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")
}
04 Complete Hook Catalog — hooks/notifs/

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])
}
Design insight
The 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, ...])
05 Pipeline 2 — The OS / Terminal Notifier

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) }
Why wrapForMultiplexer but not BEL?
Most OSC sequences get wrapped in DCS pass-through escapes so tmux/screen relay them to the outer terminal. BEL is intentionally not wrapped — inside tmux, a bare BEL triggers tmux's own 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
06 Background Task Notification Collapsing

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.

07 MCP Channel Notifications (Kairos)

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():

Gate 1

Capability

Server must declare experimental['claude/channel']

Gate 2

Runtime flag

isChannelsEnabled() — GrowthBook kill-switch

Gate 3

Auth

Requires Claude.ai OAuth token (API key users blocked)

Gate 4

Org policy

Team/Enterprise orgs must set channelsEnabled: true

Gate 5

Session opt-in

Server must be in --channels flag for this session

Gate 6

Allowlist

Plugin marketplace verification + approved-plugin ledger check

08 Full Architecture Map
graph TD subgraph "In-REPL Toast Queue" CTX["context/notifications.tsx\nuseNotifications()\naddNotification / removeNotification"] AS["AppState\n{ current: Notification|null\n queue: Notification[] }"] NR["PromptInput/Notifications.tsx\nOrchestrator + Renderer"] HOOKS["hooks/notifs/*.tsx\n14 specialized hooks"] CTX -- "setAppState()" --> AS AS -- "useAppState()" --> NR HOOKS -- "addNotification()" --> CTX NR -- "runs hooks\non mount" --> HOOKS end subgraph "OS/Terminal Notifier" SN["services/notifier.ts\nsendNotification()"] TN["ink/useTerminalNotification.ts\nwriteRaw() via context"] TTY["TTY stdout\nOSC / BEL escapes"] SN -- "channel dispatch" --> TN TN -- "OSC 9/99/BEL" --> TTY end subgraph "Background Tasks" CBN["utils/collapseBackgroundBashNotifications.ts"] MSG["Message list\n(pre-render)"] CBN -- "collapses run\nof completed\nbash tasks" --> MSG end subgraph "MCP Channel (Kairos)" MCN["services/mcp/channelNotification.ts"] Q["Command queue\nhasCommandsInQueue()"] MCN -- "wrapChannelMessage()\nenqueue" --> Q end NR -.->|"task done\n(long-running)"| SN

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.
  • immediate notifications preempt the current display and bump the displaced item back into the queue (if non-immediate).
  • The fold field lets same-key notifications merge like Array.reduce() — timeout is reset on each fold.
  • A module-level currentTimeoutId (not React state) enables synchronous timeout cancellation when immediate notifications arrive.
  • useStartupNotification abstracts 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.preferredNotifChannel and env.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

Q1. An immediate notification arrives while a high notification is currently displaying. What happens to the high notification?
Q2. What is the purpose of the fold field on BaseNotification?
Q3. Why is BEL (0x07) written to the TTY without the multiplexer wrapper, while iTerm2 OSC sequences are wrapped?
Q4. collapseBackgroundBashNotifications only collapses background bash completions in one condition — when verbose is false. What else must be true?
Q5. Which of the following is NOT one of the 6 gates in gateChannelServer()?