← Back to all posts

Notification System for Tmux and Claude Code

2025-08-04 · DevOps

Introduction

Like many technical folks these days, I use Claude Code constantly. But here’s the thing - I’m usually running multiple Claude sessions in parallel, either working on different features of the same project or juggling entirely different codebases. And when you’re deep in the zone with 5+ Claude sessions spread across tmux windows and panes, you need to know immediately when one of them needs your attention.

I tried the various web UIs and orchestrators out there - vibe-kanban, claudecodeui, Claudia. They’re nice for getting an overview, but they introduce too much friction when you live in the terminal. Context switching to a web UI just to check on Claude? That’s a workflow killer.

So I built my own notification system. Nothing fancy - just a clean way to get native macOS notifications that, when clicked, take me directly to the right tmux pane. No web UI, no extra chrome, just a notification and a single click to get back to work.

The basic flow: Claude finishes or needs input → triggers a notification → click it → boom, you’re in the right tmux pane. The whole thing runs on n8n for orchestration, Gotify for managing notifications across devices, and some simple webhook scripts for the local integration.

Who This Is For

This guide is for intermediate to advanced terminal users who:

  • Are comfortable with tmux, shell scripts, and Docker
  • Run multiple Claude Code sessions in parallel
  • Want native macOS notifications with one-click navigation to specific tmux panes
  • Prefer terminal-based workflows over web UIs

Prerequisites: macOS (with adaptations for Linux), tmux, Docker, Homebrew
Setup Time: 30-45 minutes

Architecture Overview

graph TB subgraph "Local Machine" CC[Claude Code] TM[tmux session] WH[Webhook Server] TN[terminal-notifier] GT[Ghostty] CC -->|runs in| TM WH -->|triggers| TN TN -->|on click| WH WH -->|activates| GT GT -->|contains| TM end subgraph "Docker Services" N8N[n8n Orchestrator] GOT[Gotify Server] end subgraph "Clients" BR[Browser] MB[Mobile App] end CC -->|hook event| N8N N8N -->|notification| GOT N8N -->|trigger| WH GOT -->|push| BR GOT -->|push| MB

Component Overview

The system consists of six key components:

  1. Claude Hooks - Triggers on Stop/Notification events
  2. n8n - Workflow orchestrator that routes notifications
  3. Gotify - Centralized notification server with multi-client support
  4. Webhook - Local script executor
  5. Terminal Notifier - Native macOS notifications
  6. Ghostty + tmux - Terminal emulator and multiplexer for navigation

Data Flow

graph TB A[Claude] -->|Hook| B[n8n] B --> C[Gotify] B --> D[Webhook] C --> E[Clients] D --> F[Notify] F -->|Click| G[tmux] style A fill:#fef3c7,stroke:#f59e0b style G fill:#d1fae5,stroke:#10b981
  1. A Claude Code session in tmux pane (e.g., 1:0.1) completes or needs input
  2. Claude hook triggers and calls the n8n webhook with tmux location data
  3. n8n sends notifications to both Gotify and local webhook server
  4. Gotify notifies all registered clients while webhook triggers terminal-notifier
  5. Clicking the notification calls another webhook that activates the tmux pane

Notification Example

Architecture Decisions

Why not use n8n alone instead of Gotify? While technically possible and probably easier to achieve notifications on specific applications such as Telegram, I wanted to decouple the orchestrator from the notification mechanism. Gotify is my messaging center for many different applications and it is actually hosted on a server that is not the machine where claude-code is started. Gotify also has the concepts of users, applications and clients which offer more flexibility than n8n to manage notifications properly.

Why not use n8n to execute scripts on the host? This would work if n8n was started on the same machine as where claude-code runs which is not my case. My n8n instance is hosted, as Gotify, on a different server and it used for different machines. Separating the orchestrator from the executor is still a good idea regardless and it offers the possibility to extend our notification system to receive notifications from different machines (e.g. a remote server) and execute actions there.

Why build around Claude Code? I want to build around Claude-code and not with Claude-code. As a result, I expect to be able to have Claude-code using the notification system that we will develop here. This is probably the most exciting use-case because it then becomes possible to have several claude-instance communicating with each other (spoiler alert, this is not going to end well!).

Warning

To make it easy for people to try it, I will consider that all components are started on the same machine and I will provide the instructions for macOS. In real life scenarios, you would typically separate n8n and Gotify on dedicated servers.

Quick Install (Experienced Users)

For those familiar with the stack, here’s the rapid setup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 1. Start services
docker volume create n8n_data && docker run -d --name n8n -p 5678:5678 -v n8n_data:/home/node/.n8n docker.n8n.io/n8nio/n8n
docker volume create gotify_data && docker run -d -p 8456:80 --name=gotify -v gotify_data:/app/data gotify/server

# 2. Install and configure webhook
brew install webhook
curl -L https://raw.githubusercontent.com/yourusername/claude-notifier/main/webhook.plist > ~/Library/LaunchAgents/com.github.adnanh.webhook.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.github.adnanh.webhook.plist

# 3. Setup tmux environment
echo 'if [ -n "$TMUX" ] && [ -z "$WS_TMUX_LOCATION" ]; then
  export WS_TMUX_LOCATION=$(tmux display-message -p "#{session_name}:#{window_index}.#{pane_index}")
  export WS_TMUX_SESSION_NAME=$(tmux display-message -p "#{session_name}")
  export WS_TMUX_WINDOW_NAME=$(tmux display-message -p "#{window_name}")
fi' >> ~/.zshrc

# 4. Deploy scripts and configs
mkdir -p ~/bin
curl -L https://raw.githubusercontent.com/yourusername/claude-notifier/main/scripts.tar.gz | tar -xz -C ~/bin/
curl -L https://raw.githubusercontent.com/yourusername/claude-notifier/main/.hooks.json > ~/.hooks.json

# 5. Configure Claude hooks (add to ~/.claude/settings.json)
# 6. Import n8n workflow and add Gotify token

Next steps: Configure services via web UIs and test with a Claude Code session

Implementation Guide

First, let’s prepare the services that will form the backbone of our notification system. Each service plays a specific role in the notification pipeline, and we’ll set them up in the order of their dependencies.

Service Setup

n8n Setup

n8n is our workflow orchestrator that will receive webhook calls from Claude Code and route them to the appropriate notification channels.

  1. Create Docker volume:

    1
    docker volume create n8n_data
    

  2. Start n8n container:

    1
    docker run --name n8n -p 5678:5678 -v n8n_data:/home/node/.n8n docker.n8n.io/n8nio/n8n
    

  3. Verify installation: Visit http://localhost:5678 and create your account

Troubleshooting
Gotify Setup

Gotify serves as our centralized notification server. It provides a clean API for sending notifications and supports multiple clients (browser, mobile apps, etc.).

  1. Create Docker volume:

    1
    docker volume create gotify_data
    

  2. Start Gotify container:

    1
    docker run -p 8456:80 --name=gotify -v gotify_data:/app/data gotify/server
    

  3. Verify installation: Visit http://localhost:8456 (default username/password: admin/admin)

Troubleshooting
Webhook Setup

The webhook server runs locally and executes scripts when triggered. This is what allows us to show system notifications and activate tmux panes when you click on them.

  1. Install webhook:

    1
    brew install webhook
    

  2. Create LaunchAgent configuration: Create ~/Library/LaunchAgents/com.github.adnanh.webhook.plist (adjust paths to match your username):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>           <string>com.github.adnanh.webhook</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/webhook</string>
    <string>-hooks</string>
    <string>/Users/aquemy/.hooks.json</string>
    <string>-port</string>
    <string>9000</string>
    <string>-hotreload</string>
    <string>-logfile</string>
    <string>/Users/aquemy/.webhook_log.txt</string>
  </array>
  <key>RunAtLoad</key>       <true/>
  <key>KeepAlive</key>       <true/>
  <key>StandardOutPath</key>  <string>/opt/homebrew/var/log/webhook.log</string>
  <key>StandardErrorPath</key><string>/opt/homebrew/var/log/webhook.log</string>
</dict>
</plist>
  1. Start webhook service:

    1
    launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.github.adnanh.webhook.plist
    

  2. Verify webhook is running:

Check ~/.webhook_log.txt for:

1
2
3
4
[webhook] 2025/08/04 15:15:07 version 2.8.2 starting
[webhook] 2025/08/04 15:15:07 setting up os signal watcher
[webhook] 2025/08/04 15:15:07 attempting to load hooks from /Users/aquemy/.hooks.json
[webhook] 2025/08/04 15:15:07 os signal watcher ready

Troubleshooting

Linux Alternative

On Linux, use systemd instead of launchctl. Create /etc/systemd/system/webhook.service

Tmux Configuration

For the notification system to work, Claude Code needs to know which tmux pane it’s running in. We’ll set up environment variables that capture this information automatically when a new pane is created.

  1. Add environment variables to .zshrc:

    1
    2
    3
    4
    5
    6
    # Automatically set these variables for new processes in tmux
    if [ -n "$TMUX" ] && [ -z "$WS_TMUX_LOCATION" ]; then
      export WS_TMUX_LOCATION=$(tmux display-message -p '#{session_name}:#{window_index}.#{pane_index}')
      export WS_TMUX_SESSION_NAME=$(tmux display-message -p '#{session_name}')
      export WS_TMUX_WINDOW_NAME=$(tmux display-message -p '#{window_name}')
    fi
    

  2. Reload shell configuration:

    1
    source ~/.zshrc
    

  3. Verify environment variables:

    1
    echo $WS_TMUX_LOCATION $WS_TMUX_SESSION_NAME $WS_TMUX_WINDOW_NAME
    

Expected output (values will vary):

1
1:0.1 1 node

Troubleshooting

Gotify App Configuration

Now we need to create an application in Gotify that will handle our Claude Code notifications. This application will have its own token that we’ll use in n8n.

  1. Create Gotify application:

    1
    2
    3
    4
    curl -u admin:admin \
         -F "name=Claude Code" \
         -F "description=Notifications from Claude Code" \
         http://localhost:8456/application
    

  2. Save the application token: The response includes a token (e.g., "token":"Abis_ERmy-hxAx5"):

    1
    {"id":2,"token":"Abis_ERmy-hxAx5","name":"Claude Code","description":"description=Notifications from Claude Code","internal":false,"image":"static/defaultapp.png","defaultPriority":0,"lastUsed":null}
    

  3. Configure clients:

  4. Visit http://localhost:8456/#/applications
  5. Create browser client for web notifications
  6. Optional: Install mobile app and add client

Important

Save the application token - you’ll need it for the n8n configuration!

Troubleshooting

Webhook Scripts

We need two scripts to handle the notification flow:

  1. ghostty-notify-wrapper.sh - Generates the macOS notification using terminal-notifier with contextual information from Claude Code
  2. go-tmux.sh - Handles the notification click event and switches to the correct tmux pane

Both scripts will be registered as webhooks. The notification generated by the first script will include a click action that triggers the second script. Since webhook was started with the --hotreload flag, changes to these scripts take effect immediately without restarting the service.

The architecture isolates terminal-specific components (ghostty, terminal-notifier) in these scripts, making it easy to adapt for other terminals or notification systems.

Note

Important: These scripts must use absolute paths for all executables (e.g., /opt/homebrew/bin/terminal-notifier instead of just terminal-notifier) because they run in a limited environment context.

The first script generates the macOS notification. It takes all the contextual information from Claude (tmux location, session name, project, etc.) and creates a clickable notification. The magic happens in the -execute parameter - when you click the notification, it triggers a webhook that calls our second script.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/bin/bash
# $1 = tmux_location
# $2 = tmux_session_name
# $3 = tmux_window_name
# $4 = project
# $5 = cwd
# $6 = transcript_path
# $7 = hook_event_name
# $8 = session_id
/opt/homebrew/bin/terminal-notifier \
  -subtitle "🤖 Claude is $([ "$7" = "Stop" ] && echo "done" || echo "waiting")." \
  -title "tmux s:$2 w:$3" \
  -message "Project $4 - Session $8" \
  -execute "/usr/bin/curl -X POST 'http://localhost:9000/hooks/show-ghostty?tmux_location=$1'" \
  -sound default

The second script handles the actual navigation. When you click a notification, this script parses the tmux location, switches to the right window and pane, then brings Ghostty to the foreground. The logging is verbose on purpose - when something goes wrong with tmux navigation, you want to know exactly what happened.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#!/bin/zsh

# Log ALL output
LOG="/tmp/tmux-notifier.log"
exec >> $LOG 2>&1

echo "=== CLICK EVENT: $(date) ==="
echo "PWD: $(pwd)"
echo "SHELL: $SHELL"
echo "PATH: $PATH"
echo "USER: $USER"
echo "LOCATION: $1"

# Source zsh environment
source "$HOME/.zshenv" 2>/dev/null
source "$HOME/.zshrc" 2>/dev/null

export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$HOME/bin"

echo "FINAL PATH: $PATH"

TMUX=$(whence -p tmux || echo "/usr/local/bin/tmux")
echo "TMUX binary: $TMUX"

if [[ ! -x "$TMUX" ]]; then
  echo "ERROR: tmux not found or not executable at $TMUX"
  exit 1
fi

LOCATION="$1"

if [[ -z "$LOCATION" ]]; then
  echo "ERROR: No location provided"
  exit 1
fi

if [[ "$LOCATION" =~ ^([^:]+):([0-9]+)(\.([0-9]+))?$ ]]; then
  SESSION="${match[1]}"
  WINDOW="${match[2]}"
  PANE="${match[4]}"
else
  echo "ERROR: Invalid format: $LOCATION"
  exit 1
fi

echo "Switching to: session=$SESSION, window=$WINDOW, pane=$PANE"

# Run tmux commands
"$TMUX" select-window -t "$SESSION:$WINDOW"
if [[ $? -ne 0 ]]; then
  echo "ERROR: tmux select-window failed"
else
  echo "OK: select-window succeeded"
fi

if [[ -n "$PANE" ]]; then
  "$TMUX" select-pane -t "$SESSION:$WINDOW.$PANE"
  if [[ $? -ne 0 ]]; then
    echo "WARNING: select-pane failed"
  else
    echo "OK: select-pane succeeded"
  fi
fi

# Try to focus Ghostty
osascript -e 'tell application "Ghostty" to activate'
if [[ $? -eq 0 ]]; then
  echo "OK: osascript ran successfully"
else
  echo "ERROR: osascript failed with code $?"
fi

echo "=== END ==="

Make these scripts executable with:

1
2
chmod +x ghostty-notify-wrapper.sh
chmod +x go-tmux.sh

Now we need to configure webhook to know about these scripts. The .hooks.json file defines two webhook endpoints - one for each script. The key thing to understand is how webhook passes data: { "source": "payload", "name": "tmux_location" } means “take the tmux_location field from the POST request body and pass it as a command-line argument to the script”. For the first webhook, { "source": "url", "name": "tmux_location" } means “extract tmux_location from the URL query parameters” - this is how the notification click passes data back.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[
  {
    "id": "show-ghostty",
    "execute-command": "/Users/aquemy/bin/go-tmux.sh",
    "pass-arguments-to-command": [
      { "source": "url", "name": "tmux_location" }
    ]
  },
  {
    "id": "ghostty-notify",
    "execute-command": "/Users/aquemy/bin/ghostty-notify-wrapper.sh",
    "pass-arguments-to-command": [
      { "source": "payload", "name": "tmux_location" },
      { "source": "payload", "name": "tmux_session_name" },
      { "source": "payload", "name": "tmux_window_name" },
      { "source": "payload", "name": "project" },
      { "source": "payload", "name": "cwd" },
      { "source": "payload", "name": "transcript_path" },
      { "source": "payload", "name": "hook_event_name" },
      { "source": "payload", "name": "session_id" }
    ]
  }
]

You can test the script directly with a command like:

1
./ghostty-notify-wrapper.sh "1:0.1" "Session" "Windows" "My project" "/Users/aquemy" "/Users/aquemy/.claude/projects/-Users-aquemy/291a78f4-8b7e-4c74-9d09-3abd06dc7f56.jsonl" "Stop" "291a78f4-8b7e-4c74-9d09-3abd06dc7f56"

It should generate a notification and if you click on it, it should bring you to 1:0.1 if you have such pane.

Claude Hooks

Claude hooks allow us to trigger actions when specific events occur in Claude Code. For our notification system, we’re interested in two events:

  • Stop: When Claude Code finishes executing and is waiting for the next instruction
  • Notification: When Claude Code needs user attention (e.g., asking for clarification)

Claude hooks provide a rich payload containing session information, working directory, and event type. We’ll enhance this payload with our tmux location data before forwarding it to n8n.

Understanding the Hook Payload

When a hook triggers, Claude Code provides a JSON payload with fields like: - session_id: Unique identifier for the Claude session - cwd: Current working directory - hook_event_name: The event that triggered the hook (Stop, Notification, etc.) - transcript_path: Path to the session transcript

We’ll add our tmux environment variables to this payload using jq:

1
echo '{"original": "payload"}' | jq --arg tmux_location "$WS_TMUX_LOCATION" --arg tmux_session_name "$WS_TMUX_SESSION_NAME" --arg tmux_window_name "$WS_TMUX_WINDOW_NAME" '. + {tmux_location: $tmux_location, tmux_session_name: $tmux_session_name, tmux_window_name: $tmux_window_name}'

This produces an enhanced payload:

1
2
3
4
5
6
{
  "original": "payload",
  "tmux_location": "0:1.2",
  "tmux_session_name": "Work",
  "tmux_window_name": "window name"
}

Forwarding to n8n

We’ll pipe this enhanced payload to curl to send it to our n8n webhook:

1
jq --arg tmux_location "$TMUX_LOCATION" --arg tmux_session_name "$TMUX_SESSION_NAME" --arg tmux_window_name "$TMUX_WINDOW_NAME" '. + {tmux_location: $tmux_location, tmux_session_name: $tmux_session_name, tmux_window_name: $tmux_window_name}' | curl -X POST -H "Content-Type: application/json" -d @- http://localhost:5679/webhook/claude-code-notification

The final hooks to place in ~/.claude/settings.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  ...
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "jq --arg tmux_location \"$WS_TMUX_LOCATION\" --arg tmux_session_name \"$WS_TMUX_SESSION_NAME\" --arg tmux_window_name \"$WS_TMUX_WINDOW_NAME\" '. + {tmux_location: $tmux_location, tmux_session_name: $tmux_session_name, tmux_window_name: $tmux_window_name}' | curl -X POST -H \"Content-Type: application/json\" -d @- http://localhost:5679/webhook/claude-code-notification"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "jq --arg tmux_location \"$WS_TMUX_LOCATION\" --arg tmux_session_name \"$WS_TMUX_SESSION_NAME\" --arg tmux_window_name \"$WS_TMUX_WINDOW_NAME\" '. + {tmux_location: $tmux_location, tmux_session_name: $tmux_session_name, tmux_window_name: $tmux_window_name}' | curl -X POST -H \"Content-Type: application/json\" -d @- http://localhost:5678/webhook-test/claude-code-notification"
          }
        ]
      }
    ]
  }
}

Perfect, we now need to create the n8n workflow!

n8n Workflow

Now we’ll create the n8n workflow that orchestrates our notifications. The workflow has a simple but powerful design:

  1. Webhook Trigger: Receives the enhanced payload from Claude hooks
  2. Edit Fields Node: Transforms and enriches the data (e.g., extracting project name from path)
  3. Parallel Notifications: Sends notifications to both Gotify and local webhook simultaneously

This parallel execution ensures that system notifications appear instantly while Gotify handles persistent notifications and multi-device delivery.

n8n Workflow Overview

Workflow Setup
1. Trigger Configuration

Create a new workflow and add a Webhook node. This will be the entry point for all Claude Code notifications.

Webhook Trigger Configuration

Important settings: - HTTP Method: POST - Path: claude-code-notification (must match the path in your Claude hooks) - Response Mode: “When last node finishes” (ensures proper acknowledgment)

2. Edit Fields Node

The Edit Fields node allows us to transform the incoming data. While optional, it’s useful for extracting meaningful information. For example, we extract the project name from the working directory path:

Edit Fields Configuration

Expression to extract project name:

1
{{ $json.body.cwd.split('/').last() }}

This takes the working directory path (e.g., /Users/aquemy/projects/my-app) and extracts just the project name (my-app).

3. Gotify Notification

The Gotify notification node sends alerts to all registered clients. Since n8n runs in Docker, we need to use the special Docker networking URL.

Gotify Notification Configuration

Configuration details:

  • Method: POST
  • URL: http://host.docker.internal:8456/message
  • Query Parameters: Add token with your Gotify app token
  • Body Parameters:
  • title: 🤖 Claude is {{ $if($json.hook_event_name == 'Stop', 'done', 'waiting') }} for project {{ $json.project }}
  • message: Session: {{ $json.session_id }}\nTMUX: {{ $json.tmux_location }}
  • priority: 5

The notification appears in multiple places:

Browser notification:

Browser Notification Example

Gotify UI:

Gotify UI View

4. Webhook System Notification

This node triggers our local webhook server to create the macOS system notification:

Webhook System Notification Configuration

Configuration:

  • Method: POST
  • URL: http://host.docker.internal:9000/hooks/ghostty-notify
  • Body Parameters: Forward all the relevant fields from the payload
Complete Workflow JSON
Complete Workflow JSON

Conclusion

That’s it - a simple notification system that keeps me in the flow while juggling multiple Claude sessions. No more wondering which session needs attention or losing track of where I was working. Just a clean notification and a single click to get back to the right tmux pane.

The best part? Each component does one thing well. n8n handles the orchestration, Gotify manages notifications across devices, and simple bash scripts handle the local integration. When something breaks (and it will), it’s easy to debug because each piece is isolated.

What’s Next?

I’ve been thinking about a few extensions that might be interesting:

  • Web UI with tmux controls - Show the full tmux layout with live status of each Claude session. Maybe add touch screen support or macro pad shortcuts for quick navigation.
  • Claude-to-Claude communication - Since Claude can now trigger notifications, why not let different Claude instances communicate? I tried this already - spoiler: it gets weird fast when they start coordinating.
  • Home Assistant integration - Visual feedback through smart lights when Claude needs attention. Because why not?
  • Event database - Track notification patterns to understand when Claude gets stuck most often. Could help optimize prompts.
  • Beyond tmux - Support for screen, Zellij, or even Windows Terminal.

The notification system is intentionally built to be extended. Want to add Discord notifications? Just add another node in n8n. Prefer Linux? Swap terminal-notifier for notify-send and osascript for xdotool. The architecture stays the same.

If you build something cool with this, let me know. I’m always curious to see how others adapt these tools to their workflow.

Configuration Files Appendix

This section consolidates all configuration files for easy reference. You can download these files from the GitHub repository.

Complete .hooks.json

Additional Configuration Examples

LaunchAgent plist

LaunchAgent Configuration

Claude Hooks Configuration

Claude settings.json Hooks

Shell Scripts

Notification and Navigation Scripts

n8n Workflow

Complete n8n Workflow JSON