Silencing the Ghostty bell when Claude Code asks a question

Silencing the Ghostty bell when Claude Code asks a question

👁87views
Article Summary
  • 1.
    What it is
    Silencing the Ghostty bell when Claude Code asks a question requires either disabling the terminal bell in Claude Code's settings or configuring Ghostty to ignore the BEL character, with a script to toggle between quiet and visual modes.
  • 2.
    Why it matters
    The benefit is eliminating the distracting chime without losing visual cues, and using a script that reports exactly what changed so you can confirm the fix without opening config files.
  • 3.
    Key takeaway
    Muting Ghostty's bell stops Claude Code's noise but does nothing for Slack, Mail, or other macOS notification sounds, which live in a separate, partly not scriptable layer.
~9 min read

If you run Claude Code in Ghostty, you’ve probably heard it: a small chime every time Claude finishes a task or stops to ask you something. It is useful on the first day and maddening by the third, especially in a shared office or on a call. The obvious fix is to turn off Ghostty’s bell, and the obvious test is to ring the bell and listen, but on macOS both of those can pass while the chime carries on, because the chime usually isn’t the bell at all. Everything below was tested against Ghostty 1.3.1 on macOS.

1. Two ways a terminal program can get your attention

Claude Code doesn’t play a sound itself. It sends an escape sequence to the terminal and lets the terminal decide what to do, and there are two families of sequence it can send, depending on its notification setting.

The first is the terminal bell, the ancient BEL control character (0x07) that once rang an actual bell on a teletype. Ghostty handles it through the bell-features key, which can bounce the dock icon, add a 🔔 to the window title, flash a border, play the macOS system alert sound (system) or play a custom sound file (audio, on macOS since 1.3.0).

The second is a desktop notification, sent as OSC 9 or OSC 777. Ghostty passes these to macOS Notification Center as a normal notification from the Ghostty app, and macOS plays its notification sound unless you’ve turned that off for Ghostty. The bell-features key has no say over this path at all; it is governed by a separate key, desktop-notifications, which is on by default.

That split matters because Ghostty’s default bell on macOS is already silent. The default value is no-system,no-audio,attention,title,no-border: a dock bounce and a title marker, with both sound features off. If you hear a chime when Claude Code wants you, the likeliest source is a desktop notification, and silencing the bell will do nothing about it.

2. Fix one: tell Claude Code to stop notifying

Claude Code has a notification channel setting in ~/.claude/settings.json:

{ "preferredNotifChannel": "notifications_disabled" }

That stops Claude Code sending either the bell or a desktop notification, which is the cleanest fix if Claude Code is the only thing that bothers you. Other tools keep their behaviour, so make finishing a long build, a background rsync and a shell script that pings you all still work.

It doesn’t cover anything else that runs inside the session. Claude Code hooks can emit their own terminal sequences, so a hook that sends an OSC 777 notification will still chime even with this setting in place; if you’ve written hooks that alert you, check them too.

3. Fix two: tell Ghostty to ignore both paths

The Ghostty side lives in ~/.config/ghostty/config, and there are two keys to set, not one:

bell-features = no-system,no-audio,no-attention,no-title,no-border
desktop-notifications = false

Both halves of the bell-features line are deliberate. The key takes a comma separated list, and each feature can be switched on by name or off with a no- prefix, but any feature you don’t mention keeps its default. Writing bell-features = title therefore gives you the title marker and the dock bounce, because attention is on by default and you didn’t say otherwise. Writing bell-features = with nothing after it resets the key to its default. Neither is a way to say “off”, so name all five and set each one explicitly. (bell-features = false also switches everything off, but the explicit list is easier to read and to edit later.)

If you want to keep a quiet cue, spell it out the same way:

bell-features = no-system,no-audio,no-attention,title,no-border

The available features are system, audio, attention (dock bounce on macOS), title (the 🔔 marker) and border. title is a nice middle ground, because you get a visual nudge on the tab and no noise.

Setting desktop-notifications = false stops every program in every Ghostty window from posting notifications, not just Claude Code. If you’d rather keep the banners and lose only the sound, leave it on and turn off “Play sound for notifications” for Ghostty in System Settings > Notifications instead.

Reload the config with Cmd+Shift+,, which needs no restart but only reloads the window you’re in. Every other open Ghostty window keeps running on the old config until you reload it too. Quitting and relaunching Ghostty (Cmd+Q, then reopen) reloads every window at once, which is simpler if you’ve got several open.

4. A small script to flip it

I change this depending on where I’m working, silent in the office and dock bounce at home, so it lives in a script rather than in my fingers.

A script that edits config should tell you what it changed in words you can check at a glance, and it should check its own work rather than trusting it. This one writes both keys explicitly, rejects feature names Ghostty wouldn’t recognise, runs ghostty +validate-config on the result and restores the backup if Ghostty rejects it, and then asks Ghostty what value it actually resolves from the config:

cat > ghostty-bell <<'EOF'
#!/usr/bin/env bash
# ghostty-bell: control every way Ghostty can get your attention.
# Usage: ghostty-bell [off|<feature,feature,...>]
#        bell features: system, audio, attention, title, border
#        notify:        allow desktop notifications (OSC 9 / OSC 777)
# Anything you don't name is switched off explicitly. Ghostty keeps its
# default for any bell feature left unlisted, and an empty value resets
# the whole key to its default, so neither is a safe way to say "off".
set -euo pipefail

xdg="${XDG_CONFIG_HOME:-$HOME/.config}"
cfg="${GHOSTTY_CONFIG:-$xdg/ghostty/config}"
want="${1-off}"
[ "$want" = "off" ] && want=""
want=$(printf '%s' "$want" | tr -d ' ')
all="system audio attention title border"

# Find the ghostty binary, if there is one
G=""
for c in ghostty /Applications/Ghostty.app/Contents/MacOS/ghostty; do
  if command -v "$c" >/dev/null 2>&1; then G=$(command -v "$c"); break; fi
done

# Reject anything that is not a real feature name
for f in $(printf '%s' "$want" | tr ',' ' '); do
  case " $all notify " in
    *" $f "*) ;;
    *) echo "unknown feature: $f (valid: system, audio, attention, title, border, notify)" >&2; exit 1 ;;
  esac
done

# Explicit values for both keys
val=""
for f in $all; do
  case ",$want," in *",$f,"*) item="$f" ;; *) item="no-$f" ;; esac
  val="${val:+$val,}$item"
done
case ",$want," in *",notify,"*) notify=true ;; *) notify=false ;; esac

label() {
  case "$1" in
    audio)     echo "custom sound" ;;
    system)    echo "system alert sound" ;;
    attention) echo "dock bounce" ;;
    title)     echo "title marker" ;;
    border)    echo "border flash" ;;
    *)         echo "$1" ;;
  esac
}

# Ghostty's own default, so "unset" can be described truthfully
default_bell="no-system,no-audio,attention,title,no-border"
if [ -n "$G" ]; then
  d=$("$G" +show-config --default 2>/dev/null | sed -n 's/^bell-features = //p')
  [ -n "$d" ] && default_bell="$d"
fi

describe_bell() {
  local v t out="" suffix=""
  v=$(printf '%s' "$1" | tr -d ' ')
  case "$v" in
    ""|unset) v="$default_bell"; suffix=" (Ghostty default)" ;;
    false)    v="no-system,no-audio,no-attention,no-title,no-border" ;;
    true)     v="system,audio,attention,title,border" ;;
  esac
  for t in $(printf '%s' "$v" | tr ',' ' '); do
    case "$t" in no-*) continue ;; esac
    out="${out:+$out, }$(label "$t")"
  done
  printf '%s%s' "${out:-nothing}" "$suffix"
}

describe_notify() {
  case "$(printf '%s' "$1" | tr -d ' ')" in
    false) printf 'off' ;;
    true)  printf 'on' ;;
    *)     printf 'on (Ghostty default)' ;;
  esac
}

current() {
  grep "^[[:space:]]*$1[[:space:]]*=" "$cfg" 2>/dev/null | tail -n1 | sed 's/^[^=]*=[[:space:]]*//' || true
}

set_key() {
  local key="$1" line="$1 = $2"
  if grep -q "^[[:space:]]*$key[[:space:]]*=" "$cfg"; then
    sed -i.tmp "s|^[[:space:]]*$key[[:space:]]*=.*|$line|" "$cfg" && rm -f "$cfg.tmp"
  else
    printf '%s\n' "$line" >> "$cfg"
  fi
}

mkdir -p "$(dirname "$cfg")"; touch "$cfg"
was_bell=$(current bell-features);           grep -q '^[[:space:]]*bell-features' "$cfg" || was_bell=unset
was_notify=$(current desktop-notifications); grep -q '^[[:space:]]*desktop-notifications' "$cfg" || was_notify=unset

cp "$cfg" "$cfg.bak"
set_key bell-features "$val"
set_key desktop-notifications "$notify"

# Refuse to leave behind a config Ghostty cannot parse
if [ -n "$G" ]; then
  if ! errs=$("$G" +validate-config --config-file="$cfg" 2>&1) || [ -n "$errs" ]; then
    cp "$cfg.bak" "$cfg"
    echo "Ghostty rejected the new config, original restored:" >&2
    printf '%s\n' "$errs" >&2
    exit 1
  fi
fi

# What Ghostty resolves from its default config location (skipped when
# GHOSTTY_CONFIG points somewhere Ghostty would not look on its own)
resolved=""
if [ -n "$G" ] && [ -z "${GHOSTTY_CONFIG:-}" ]; then
  resolved=$("$G" +show-config 2>/dev/null | sed -n 's/^bell-features = //p')
  [ -n "$resolved" ] || resolved="$default_bell (default)"
fi

if [ -t 1 ]; then b=$(tput bold); dim=$(tput dim); g=$(tput setaf 2); r=$(tput sgr0)
else b=""; dim=""; g=""; r=""; fi

same() { [ "$(printf '%s' "$1" | tr -d ' ')" = "$2" ]; }

echo
if same "$was_bell" "$val" && same "$was_notify" "$notify"; then
  echo "  ${b}Ghostty bell unchanged${r}"
else
  echo "  ${g}${b}Ghostty bell updated${r}"
fi
printf '  %sbell%s           %s -> %s%s%s\n' "$dim" "$r" "$(describe_bell "$was_bell")" "$b" "$(describe_bell "$val")" "$r"
printf '  %snotifications%s  %s -> %s%s%s\n' "$dim" "$r" "$(describe_notify "$was_notify")" "$b" "$(describe_notify "$notify")" "$r"
echo
printf '  %sconfig%s    %s\n' "$dim" "$r" "$cfg"
printf '  %sbackup%s    %s\n' "$dim" "$r" "$cfg.bak"
[ -n "$G" ] && printf '  %svalidated%s ghostty +validate-config passed\n' "$dim" "$r"
[ -n "$resolved" ] && printf '  %sresolves%s  bell-features = %s\n' "$dim" "$r" "$resolved"
echo
echo "  Open windows keep their old settings until reloaded: Cmd+Shift+, in each one, or Cmd+Q and relaunch."
echo
EOF
chmod +x ghostty-bell

It rewrites each line if it exists and appends it if it doesn’t, keeps a .bak each time, says unchanged when nothing moved, and strips the colour codes when output isn’t a terminal so that piping it stays clean. When a key is missing or empty, it describes Ghostty’s real default rather than calling it silent, which is the mistake that makes an empty value so easy to trust.

5. Calling it

The argument is the list of things you want switched on, and the script turns everything else off explicitly. notify is the one extra word: it allows desktop notifications, which are otherwise switched off. off, or no argument at all, turns everything off.

./ghostty-bell off              # total silence: no bell of any kind, no notifications
./ghostty-bell                  # same thing, because off is the default
./ghostty-bell title            # quiet, but the tab gets a 🔔 so you notice on return
./ghostty-bell attention        # no sound, dock icon bounces (macOS)
./ghostty-bell title,attention  # both visual cues, still no audio
./ghostty-bell title,notify     # title marker plus desktop notification banners
./ghostty-bell system           # the macOS alert sound back on

To target a different config file, set GHOSTTY_CONFIG; the script still validates the file, but skips the resolve check because Ghostty wouldn’t read that file on its own:

GHOSTTY_CONFIG=~/work/ghostty.conf ./ghostty-bell off

Each run prints the before and after. Here it is fixing a config that had the empty bell-features = line:

$ ./ghostty-bell off

  Ghostty bell updated
  bell           dock bounce, title marker (Ghostty default) -> nothing
  notifications  on (Ghostty default) -> off

  config    /Users/you/.config/ghostty/config
  backup    /Users/you/.config/ghostty/config.bak
  validated ghostty +validate-config passed
  resolves  bell-features = no-system,no-audio,no-attention,no-title,no-border

  Open windows keep their old settings until reloaded: Cmd+Shift+, in each one, or Cmd+Q and relaunch.

Run it twice and the second run says Ghostty bell unchanged, which is the tell that you already did this and forgot. A typo such as ./ghostty-bell titel stops with an error and leaves the file alone.

6. Testing it properly

The test I used to recommend was printf '\a', and silence meant success. That test is valid in the narrow sense that it rings the bell, but it only exercises one of the two paths, and on macOS it is silent with Ghostty’s default settings anyway. It passes before you’ve changed anything, so it can’t tell you whether a fix worked, and it says nothing about desktop notifications, which is where the chime usually comes from.

A useful test fires each path separately and asks you what you heard. It also waits a few seconds before each one, so you can switch to another app first, because Ghostty only bounces the dock when it is unfocused and that is also the situation you care about when Claude Code wants you:

cat > ghostty-bell-test <<'EOF'
#!/usr/bin/env bash
# ghostty-bell-test: fire each thing that can make Ghostty chime, one at a
# time, so you can hear which one is responsible. Run it inside Ghostty.
set -uo pipefail

if [ "${TERM_PROGRAM:-}" != "ghostty" ]; then
  echo "Run this inside a Ghostty window (TERM_PROGRAM is '${TERM_PROGRAM:-unset}')." >&2
  exit 1
fi

names=("Terminal bell (BEL)" "Desktop notification (OSC 9)" "Desktop notification (OSC 777)")
seqs=($'\a' $'\e]9;ghostty-bell-test: OSC 9\e\\' $'\e]777;notify;ghostty-bell-test;OSC 777\e\\')
results=()

echo
echo "  Each test waits 5 seconds so you can switch to another app first;"
echo "  Ghostty only bounces the dock and posts some notifications when unfocused."
for i in 0 1 2; do
  echo
  printf '  %d/3  %s: switch away now, firing in ' "$((i+1))" "${names[$i]}"
  for s in 5 4 3 2 1; do printf '%s ' "$s"; sleep 1; done
  printf '%s' "${seqs[$i]}" > /dev/tty
  sleep 2
  printf '\n       Did you hear a sound? [y/N] '
  read -r ans
  case "$ans" in [yY]*) results+=("sound") ;; *) results+=("silent") ;; esac
done

echo
echo "  Results"
for i in 0 1 2; do printf '    %-32s %s\n' "${names[$i]}" "${results[$i]}"; done
echo
if [ "${results[0]}" = "sound" ]; then
  echo "  The bell itself makes noise: system or audio is on in bell-features."
  echo "  Fix: ghostty-bell off (or keep only the visual features you want)."
fi
if [ "${results[1]}" = "sound" ] || [ "${results[2]}" = "sound" ]; then
  echo "  Desktop notifications make noise, and bell-features does not control them."
  echo "  Fix: desktop-notifications = false in the Ghostty config (ghostty-bell off does this),"
  echo "  or turn off 'Play sound for notifications' for Ghostty in System Settings > Notifications."
fi
if [ "${results[*]}" = "silent silent silent" ]; then
  echo "  Ghostty is silent on every path. If Claude Code still chimes, the sound is"
  echo "  coming from outside the terminal, such as a hook script or another app."
fi
echo
EOF
chmod +x ghostty-bell-test

Run it inside a Ghostty window, after reloading:

./ghostty-bell-test

The summary names the path that made the noise and the setting that controls it. If all three are silent and Claude Code still chimes, the sound is coming from outside the terminal, and a hook script that sends its own notification is the first thing to check.

Two other checks are worth knowing. To see what Ghostty resolves from your config, run ghostty +show-config | grep bell-features, using /Applications/Ghostty.app/Contents/MacOS/ghostty if ghostty isn’t on your path. It prints only settings that differ from the default, so no output means you are running the defaults. And if the result surprises you, check for a second config file: on macOS Ghostty also reads ~/Library/Application Support/com.mitchellh.ghostty/config, and if both exist it’s easy to spend ten minutes editing the wrong one.

7. The bell was never the whole problem

Quietening Ghostty stops Claude Code’s own noise, but it doesn’t touch the rest of macOS. Slack, Mail, Messages, Calendar and every other app with a notification sound keep going, and none of them read ~/.config/ghostty/config, because that noise comes from a different layer entirely: Notification Center, not the terminal.

macOS splits this into two halves, and only one of them is safely scriptable. The global half, meaning the “Play sound effects” toggle, the volume change feedback beep and the system alert volume, lives in NSGlobalDomain, and defaults write can flip it without asking permission.

The per app half, whether Mail or Slack plays a sound for its own notifications, has no supported scripting interface. Apple keeps those settings in Notification Center’s own preferences and expects you to change them by clicking in System Settings, which is a sensible protection against any process silently disabling your alerts.

The same lesson from the ghostty-bell script applies here: don’t just say “done”, say what changed. defaults write gives no feedback of its own, so this version reads each setting before it writes and reports was → now for every one, the same way ghostty-bell reports its before and after:

cat > mute_notifications.sh <<'EOF'
#!/bin/bash
#
# mute_notifications.sh
# Review and silence notification sounds on macOS.
#
# What this script CAN safely automate:
#   - Mute system UI sound effects (the "Play sound effects" toggle)
#   - Mute the volume-change feedback sound
#   - Mute the system alert/beep volume
#
# What macOS does NOT let a script change (Apple offers no supported
# way to script per-app notification settings; they are changed by a
# human clicking in System Settings):
#   - Per-app "Allow sound" toggles for Mail, Messages, Slack, etc.
#
# For that part, this script opens the right settings pane and prints
# a checklist so you can knock it out in under a minute.
#
# It reports what it actually changed (was -> now for each setting),
# not just that it ran.

set -euo pipefail

if [ -t 1 ]; then b=$(tput bold); dim=$(tput dim); g=$(tput setaf 2); r=$(tput sgr0)
else b=""; dim=""; g=""; r=""; fi

onoff() { [ "$1" = "0" ] && echo "off" || echo "on"; }

read_default() {
  defaults read NSGlobalDomain "$1" 2>/dev/null || echo "unset"
}

echo
echo "  ${b}macOS Notification Sound Cleanup${r}"
echo

# --- capture "was" state before changing anything ---
was_ui=$(read_default com.apple.sound.uiaudio.enabled)
was_feedback=$(read_default com.apple.sound.beep.feedback)
was_volume=$(read_default com.apple.sound.beep.volume)

# --- apply changes ---
defaults write NSGlobalDomain com.apple.sound.uiaudio.enabled -int 0
defaults write NSGlobalDomain com.apple.sound.beep.feedback -int 0
defaults write NSGlobalDomain com.apple.sound.beep.volume -float 0.0

# Restart the settings daemon so changes take effect without a full logout
killall SystemUIServer 2>/dev/null || true

# --- capture "now" state and report was -> now for each ---
now_ui=$(read_default com.apple.sound.uiaudio.enabled)
now_feedback=$(read_default com.apple.sound.beep.feedback)
now_volume=$(read_default com.apple.sound.beep.volume)

report_bool() {
  local label="$1" was="$2" now="$3"
  local was_l now_l
  [ "$was" = "unset" ] && was_l="on (default)" || was_l="$(onoff "$was")"
  now_l="$(onoff "$now")"
  if [ "$was_l" = "$now_l" ]; then
    printf '  %s%-28s%s %sunchanged%s (%s)\n' "$dim" "$label" "$r" "$dim" "$r" "$now_l"
  else
    printf '  %s%-28s%s %s%s%s -> %s%s%s\n' "$dim" "$label" "$r" "$dim" "$was_l" "$r" "$g$b" "$now_l" "$r"
  fi
}

echo "  ${b}${g}Changes applied${r}"
report_bool "UI sound effects"          "$was_ui" "$now_ui"
report_bool "Volume-change feedback"    "$was_feedback" "$now_feedback"
echo "  ${dim}Alert/beep volume${r}        ${dim}${was_volume:-unset}${r} -> ${g}${b}0.0 (muted)${r}"
echo
echo "  ${dim}These persist until you change them back; no restart needed.${r}"
echo

# Open Notification settings for the manual per-app review
echo "  Opening System Settings > Notifications for the manual per-app review..."
open "x-apple.systempreferences:com.apple.preference.notifications"

cat <<INNER

------------------------------------------------------------
Manual checklist (Apple requires a click here, not a script):
------------------------------------------------------------
In System Settings > Notifications, click each app you want to
quiet and turn OFF "Play sound for notifications". Common noisy
ones to check first:

  [ ] Mail
  [ ] Messages
  [ ] Calendar
  [ ] Reminders
  [ ] FaceTime
  [ ] Slack / Teams / Zoom (if installed)
  [ ] App Store
  [ ] News

Tip: You can also select an app and toggle "Allow Notifications"
off entirely if you don't want banners either, not just sound.

For a temporary blanket mute instead of per-app changes, turn on
a Focus mode (Control Center > Focus); it silences all sounds
and banners until you turn it off.
------------------------------------------------------------

To verify the global mute worked, trigger a volume change and
listen for the beep:

  osascript -e 'set volume output volume 50'

No sound = feedback beep is off.
INNER
EOF
chmod +x mute_notifications.sh

It needs no sudo and no restart. Run it:

./mute_notifications.sh

Each run tells you exactly what moved:

$ ./mute_notifications.sh

  macOS Notification Sound Cleanup

  Changes applied
  UI sound effects            on (default) -> off
  Volume-change feedback      on (default) -> off
  Alert/beep volume           unset -> 0.0 (muted)

  These persist until you change them back; no restart needed.

  Opening System Settings > Notifications for the manual per-app review...

Run it a second time and the settings that already changed report unchanged (off) instead of pretending to flip them again, which is the same tell as ghostty-bell saying “unchanged”.

It mutes the three global sound settings immediately, then hands you off to System Settings already open on the right pane, with the checklist printed right there in the terminal so you’re not hunting for what to click next.

Verifying it worked

The global part is quick to check: trigger a volume change and listen for the beep.

osascript -e 'set volume output volume 50'

No sound means the feedback beep is off. The per app part you verify the obvious way, by sending yourself a test message in each app you just muted and confirming it arrives silently.

Combined with the Ghostty fix above, that’s the full stack quiet: Claude Code’s own bell, the terminal’s handling of it, and the rest of the OS underneath.

Leave a comment

Your email address will not be published. Your first comment is held for approval.