Copilot CLI · Notification System
Engineering case study

Context-aware Windows notifications for GitHub Copilot CLI.

A native-hook + Windows automation layer that knows when Copilot needs your attention — and when you are already looking at the exact Copilot tab.

Copilot CLI hooksPowerShellWin32UI AutomationWindows TerminalBurntToast
01Copilot CLInative lifecycle event
02Hooksnotification-hooks.json
03PowerShellcopilot-notify.ps1
04Windows contextWin32 + UI Automation
05ToastBurntToast
Now available as an npm package

Install the CLI, then enable the integration.

The current release is copilot-notify@0.1.3. Installing the npm package installs the CLI; the separate copilot-notify install command configures the Copilot integration.

npm install -g copilot-notify
copilot-notify install
Verify: copilot-notify status copilot-notify test
Remove it cleanly

Integration first, CLI second.

Run the integration uninstaller before removing the npm package itself.

copilot-notify uninstall
npm uninstall -g copilot-notify
The key idea

Copilot CLI already exposes lifecycle events. The custom layer decides whether those events should interrupt the user based on the actual foreground application and the selected Windows Terminal tab.

Live event model

What can trigger a notification?

These are the four events currently wired into the Windows notification layer.

permission_prompt

Permission

Copilot needs approval to execute a tool.

Windows notification
Copilot needs approvalCopilot is waiting for your permission.
agent_completed

Subagent complete

A background Copilot agent or subagent has finished.

Windows notification
Copilot subagent finishedA background Copilot agent has completed its work.
elicitation_dialog

Input required

Copilot is waiting for additional information.

Windows notification
Copilot needs your inputCopilot is waiting for additional information.
agentStop

Main agent finished

The main Copilot agent has finished a turn.

Windows notification
Copilot finishedCopilot has finished its task.
Attention decision
Same event, different user context.
Decision
No notificationThe exact Copilot tab is selected, so the event is not surfaced as a Windows toast.
Decision
Show notificationWindows Terminal is foreground, but another tab is selected. Copilot is effectively in the background.
Decision
Show notificationAnother application owns the foreground window, so the Copilot session is not visible.

1. Purpose

This document records the Windows notification system built around GitHub Copilot CLI.

The goal was not simply to make Copilot produce notifications. Copilot CLI already exposes notification hooks. The goal was to build a Windows desktop notification layer that is context-aware:

  • Notify when Copilot needs permission.
  • Notify when Copilot needs user input.
  • Notify when a background Copilot agent/subagent finishes.
  • Notify when the main Copilot agent finishes a turn.
  • Do not show a Windows toast when the user is already looking at that exact Copilot CLI tab.
  • Show the toast when Copilot is running in another Windows Terminal tab.
  • Show the toast when the user has moved to another application such as Brave/Chrome/VS Code.

The resulting system uses native Copilot CLI hooks for event detection and custom PowerShell + Windows APIs + UI Automation + BurntToast for the Windows-side behavior.


2. What is native Copilot CLI functionality vs. what we built

Native Copilot CLI

Copilot CLI provides a notification hook. The current hook reference documents notification types including:

  • shell_completed
  • shell_detached_completed
  • agent_completed
  • agent_idle
  • permission_prompt
  • elicitation_dialog

The CLI also exposes agentStop, which fires when the main agent finishes a turn.

The notification hook is asynchronous/fire-and-forget and supports a matcher against notification_type.

Custom functionality built here

Copilot CLI does not provide the exact desktop behavior we wanted:

Only show a Windows toast when the Copilot session is not currently visible to the user.

The custom layer therefore handles:

  1. Windows toast rendering through BurntToast.
  2. Foreground-window detection through Win32.
  3. Windows Terminal tab discovery through UI Automation.
  4. Detection of the exact selected Terminal tab.
  5. A stable Copilot tab identity based on WT_SESSION.
  6. Suppression of the toast when that Copilot tab is selected.

Conceptually:

                Copilot CLI
                    |
                    | native hook event
                    v
          notification-hooks.json
                    |
                    v
          copilot-notify.ps1
                    |
          +---------+---------+
          |                   |
          v                   v
   Foreground check      Tab-selection check
          |                   |
          +---------+---------+
                    |
          +---------+---------+
          |                   |
    Copilot tab visible   Copilot in background
          |                   |
       no toast              toast

3. Final architecture

GitHub Copilot CLI
        |
        | notification / agentStop
        v
~/.copilot/hooks/notification-hooks.json
        |
        | invokes PowerShell
        v
~/.copilot/hooks/copilot-notify.ps1
        |
        +--> Is foreground app Windows Terminal?
        |
        +--> If yes, inspect Windows Terminal UI Automation
        |
        +--> Find "Copilot [SESSION]"
        |
        +--> Is that tab selected?
        |        |
        |        +--> Yes: EXIT, no toast
        |        |
        |        +--> No: show toast
        |
        +--> If foreground app is not Windows Terminal:
                 show toast
                        |
                        v
                  BurntToast
                        |
                        v
                Windows notification

4. Files created

The system uses these files:

%USERPROFILE%\.copilot\
└── hooks\
    ├── notification-hooks.json
    └── copilot-notify.ps1

A PowerShell profile wrapper was also added so copilot can create a stable terminal-tab title:

$PROFILE

The BurntToast module is installed as a PowerShell module.


5. Notification events currently configured

The current configuration sends notifications for four events.

Event Meaning Current toast
permission_prompt Copilot needs approval to execute something Copilot needs approval
agent_completed A background/subagent finishes Copilot subagent finished
elicitation_dialog Copilot needs additional user information Copilot needs your input
agentStop Main Copilot agent finishes a turn Copilot finished

The current notification text is:

permission_prompt

Title:   Copilot needs approval
Message: Copilot is waiting for your permission.

agent_completed

Title:   Copilot subagent finished
Message: A background Copilot agent has completed its work.

elicitation_dialog

Title:   Copilot needs your input
Message: Copilot is waiting for additional information.

agentStop

Title:   Copilot finished
Message: Copilot has finished its task.

The following notification types are not currently configured:

shell_completed
shell_detached_completed
agent_idle

They can be added later without changing the foreground/background mechanism.


6. notification-hooks.json

The current configuration is:

{
  "version": 1,
  "hooks": {
    "notification": [
      {
        "type": "command",
        "matcher": "permission_prompt",
        "powershell": "& \"$env:USERPROFILE\\.copilot\\hooks\\copilot-notify.ps1\" -Title 'Copilot needs approval' -Message 'Copilot is waiting for your permission.'",
        "timeoutSec": 5
      },
      {
        "type": "command",
        "matcher": "agent_completed",
        "powershell": "& \"$env:USERPROFILE\\.copilot\\hooks\\copilot-notify.ps1\" -Title 'Copilot subagent finished' -Message 'A background Copilot agent has completed its work.'",
        "timeoutSec": 5
      },
      {
        "type": "command",
        "matcher": "elicitation_dialog",
        "powershell": "& \"$env:USERPROFILE\\.copilot\\hooks\\copilot-notify.ps1\" -Title 'Copilot needs your input' -Message 'Copilot is waiting for additional information.'",
        "timeoutSec": 5
      }
    ],
    "agentStop": [
      {
        "type": "command",
        "powershell": "& \"$env:USERPROFILE\\.copilot\\hooks\\copilot-notify.ps1\" -Title 'Copilot finished' -Message 'Copilot has finished its task.'",
        "timeoutSec": 5
      }
    ]
  }
}

This file is responsible only for which Copilot events invoke the notification script.

It does not decide whether the toast should be displayed.

That decision is made by copilot-notify.ps1.


7. copilot-notify.ps1

The current script is:

param(
    [Parameter(Mandatory=$true)]
    [string]$Title,

    [Parameter(Mandatory=$true)]
    [string]$Message
)

# ------------------------------------------------------------
# Configuration
# ------------------------------------------------------------

$copilotTabPrefix = "Copilot ["

# ------------------------------------------------------------
# Win32 APIs
# ------------------------------------------------------------

Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;

public class Win32 {
    [DllImport("user32.dll")]
    public static extern IntPtr GetForegroundWindow();

    [DllImport("user32.dll")]
    public static extern uint GetWindowThreadProcessId(
        IntPtr hWnd,
        out uint processId
    );
}
"@

# ------------------------------------------------------------
# Get foreground window
# ------------------------------------------------------------

$foregroundHwnd = [Win32]::GetForegroundWindow()

if ($foregroundHwnd -eq [IntPtr]::Zero) {
    # Detection failed -> fail open and show notification.
    Import-Module BurntToast
    New-BurntToastNotification -Text $Title, $Message
    exit 0
}

$foregroundPid = 0

[Win32]::GetWindowThreadProcessId(
    $foregroundHwnd,
    [ref]$foregroundPid
) | Out-Null

$foregroundProcess = Get-Process `
    -Id $foregroundPid `
    -ErrorAction SilentlyContinue

# ------------------------------------------------------------
# Is the foreground application Windows Terminal?
# ------------------------------------------------------------

if (
    -not $foregroundProcess -or
    $foregroundProcess.ProcessName -ne "WindowsTerminal"
) {
    # Chrome, Edge, VS Code, etc. is foreground.
    Import-Module BurntToast
    New-BurntToastNotification -Text $Title, $Message
    exit 0
}

# ------------------------------------------------------------
# Determine this Copilot session's expected tab name.
#
# The Copilot launcher sets:
#
#   Copilot [XXXXXXXX]
#
# using WT_SESSION.
# ------------------------------------------------------------

$session = $env:WT_SESSION

if (-not $session) {
    # Not running inside Windows Terminal.
    Import-Module BurntToast
    New-BurntToastNotification -Text $Title, $Message
    exit 0
}

$cleanSession = $session.Replace("-", "")

if ($cleanSession.Length -lt 8) {
    Import-Module BurntToast
    New-BurntToastNotification -Text $Title, $Message
    exit 0
}

$shortSession = $cleanSession.Substring(0, 8)

$expectedTabName = "Copilot [$shortSession]"

# ------------------------------------------------------------
# Windows UI Automation
# ------------------------------------------------------------

try {
    Add-Type -AssemblyName UIAutomationClient
    Add-Type -AssemblyName UIAutomationTypes

    $root = [System.Windows.Automation.AutomationElement]::RootElement

    # Find Windows Terminal windows.
    $terminalCondition =
        New-Object System.Windows.Automation.PropertyCondition(
            [System.Windows.Automation.AutomationElement]::ClassNameProperty,
            "CASCADIA_HOSTING_WINDOW_CLASS"
        )

    $terminals = $root.FindAll(
        [System.Windows.Automation.TreeScope]::Children,
        $terminalCondition
    )

    $copilotTabIsSelected = $false

    foreach ($terminal in $terminals) {

        # Only inspect the foreground Terminal window.
        if (
            $terminal.Current.NativeWindowHandle -ne
            $foregroundHwnd.ToInt32()
        ) {
            continue
        }

        $tabCondition =
            New-Object System.Windows.Automation.PropertyCondition(
                [System.Windows.Automation.AutomationElement]::ControlTypeProperty,
                [System.Windows.Automation.ControlType]::TabItem
            )

        $tabs = $terminal.FindAll(
            [System.Windows.Automation.TreeScope]::Descendants,
            $tabCondition
        )

        foreach ($tab in $tabs) {

            $tabName = $tab.Current.Name

            if ($tabName -ne $expectedTabName) {
                continue
            }

            try {
                $selectionPattern = $tab.GetCurrentPattern(
                    [System.Windows.Automation.SelectionItemPattern]::Pattern
                )

                if ($selectionPattern.Current.IsSelected) {
                    $copilotTabIsSelected = $true
                }
            }
            catch {
                # Continue; fail open below.
            }
        }
    }

    # --------------------------------------------------------
    # Copilot tab is currently visible.
    # Do NOT show notification.
    # --------------------------------------------------------

    if ($copilotTabIsSelected) {
        exit 0
    }
}
catch {
    # UI Automation failed.
    # Fail open so notifications are not silently lost.
}

# ------------------------------------------------------------
# Copilot is in the background.
# Show notification.
# ------------------------------------------------------------

Import-Module BurntToast

New-BurntToastNotification -Text $Title, $Message

8. Why the first approach failed

The original approach compared the current PowerShell/console title with the Windows Terminal window title.

That was unreliable because Windows Terminal has separate concepts for:

  • the shell/profile,
  • the tab title,
  • the application/window title,
  • and the currently selected tab.

Windows Terminal documents that tab titles may be controlled by the shell/application and that panes/tabs can have their own titles.

The failed approach effectively answered:

"Does this title equal that title?"

It did not answer:

"Is the exact Terminal tab containing this Copilot session currently selected?"

This is why a toast appeared even though the Copilot tab was visible.


9. The final tab-identification mechanism

The solution uses WT_SESSION.

Every Windows Terminal session exposes:

$env:WT_SESSION

The PowerShell wrapper takes the first eight hexadecimal characters and changes the tab title to:

Copilot [XXXXXXXX]

Example:

Copilot [3f5f2596]

The notification script runs inside that same session and reads:

$env:WT_SESSION

It reconstructs:

Copilot [3f5f2596]

It then asks Windows UI Automation for the TabItems exposed by Windows Terminal and checks SelectionItemPattern.IsSelected.

The important mapping is therefore:

Copilot process
      |
      +--> WT_SESSION = 3f5f2596
                     |
                     v
              Copilot [3f5f2596]
                     |
                     v
             Windows Terminal
               TabItem.Name
                     |
                     v
             IsSelected = True?

10. Foreground-window detection

The script first determines which application owns the Windows foreground window.

It uses:

GetForegroundWindow()

to get the handle of the window currently receiving user interaction.

It then uses:

GetWindowThreadProcessId()

to identify the owning process.

The result is used to distinguish:

Windows Terminal is foreground

from:

Brave/Chrome/VS Code/etc. is foreground

If a non-Terminal application is foreground, the script immediately displays the toast.

This is more precise than simply checking whether a WindowsTerminal.exe process exists.


11. Windows UI Automation

Windows UI Automation exposes Terminal tabs as TabItem controls.

The relevant properties/patterns are:

ControlType = TabItem
SelectionItemPattern.IsSelected

The diagnostic performed during development produced:

TAB: [Copilot [3f5f2596]] Selected=False
TAB: [Windows PowerShell] Selected=False
TAB: [Windows PowerShell] Selected=True

This proved that the machine exposes the selected Windows Terminal tab through UI Automation.

Microsoft's UI Automation model requires tab items to support the selection-item pattern, making IsSelected the appropriate mechanism for determining the currently selected tab.


12. PowerShell copilot wrapper

The PowerShell profile contains a wrapper named copilot.

Conceptually:

function copilot {
    $session = $env:WT_SESSION

    if (-not $session) {
        ...
    }

    $shortSession = $session.Replace("-", "").Substring(0, 8)

    $oldTitle = $Host.UI.RawUI.WindowTitle

    try {
        $Host.UI.RawUI.WindowTitle = "Copilot [$shortSession]"

        $realCopilot = Get-Command copilot -CommandType Application |
            Select-Object -First 1

        & $realCopilot.Source @args
    }
    finally {
        $Host.UI.RawUI.WindowTitle = $oldTitle
    }
}

This lets the normal workflow remain:

copilot

instead of requiring a special launcher command.

The function itself is only responsible for giving the session a stable Terminal-tab identity. It does not generate notifications.


13. Copilot terminal-title setting

Copilot CLI normally supports:

"updateTerminalTitle": true

which updates the terminal title with the current intent.

For this notification system that behavior is disabled:

"updateTerminalTitle": false

The reason is important.

We need the tab title to remain a stable identifier:

Copilot [3f5f2596]

instead of changing to:

Create Notification Test File
Analyze Repository
Implement Authentication
...

If the title changed continually, the notification script would no longer have a stable UI Automation identifier.


14. BurntToast

BurntToast is the Windows PowerShell module used to render the actual desktop toast.

The final rendering call is:

Import-Module BurntToast

New-BurntToastNotification `
    -Text $Title, $Message

BurntToast was tested independently before integrating it into the hook.

A successful test produced a Windows notification in the lower-right corner.


15. Current behavior matrix

User state when event occurs Notification
Copilot tab is selected and visible No
Another Windows Terminal tab is selected Yes
Another Windows Terminal window is selected Yes
Brave/Chrome is foreground Yes
VS Code is foreground Yes
UI Automation cannot determine the tab Yes (fail-open)
WT_SESSION unavailable Yes (fail-open)
Foreground-window detection fails Yes (fail-open)

The fail-open strategy was intentional.

A detection failure should not silently prevent an important Copilot notification.


16. Verified scenarios

The system was manually tested in three scenarios.

Scenario A — Copilot tab remains visible

A task was submitted to Copilot and the user stayed on the same Copilot tab.

Result:

No toast

Scenario B — Another Windows Terminal tab becomes active

A Copilot task was started and the user switched to another Terminal tab.

Result:

Toast displayed

Scenario C — Another application becomes active

A Copilot task was started and the user switched to Brave.

Result:

Toast displayed

These three tests confirm that the key foreground/background behavior is working.


17. Installation — the npm path

The project is distributed as the public copilot-notify npm package. The npm install and integration install are deliberately separate steps so installing the CLI does not modify Copilot configuration until the user explicitly enables the integration.

1. Install the CLI

npm install -g copilot-notify

This installs the copilot-notify command. It does not install the Copilot hooks or modify the Copilot profile by itself.

2. Install the integration

copilot-notify install

This checks the Windows prerequisites, installs the hook and PowerShell implementation, bundles the Copilot mascot, installs the stable Terminal-title wrapper, sets updateTerminalTitle to false, and records the installation state.

3. Verify and test

copilot-notify status
copilot-notify test

After copilot-notify install, start a new PowerShell/Windows Terminal session so the installed copilot wrapper is loaded, then restart Copilot CLI if it was already running.

4. Uninstall the integration

copilot-notify uninstall

This removes or restores configuration owned by Copilot Notify while preserving unrelated Copilot configuration where possible.

5. Uninstall the CLI

npm uninstall -g copilot-notify
Complete lifecycle

npm install -g copilot-notifycopilot-notify install → use Copilot → copilot-notify uninstallnpm uninstall -g copilot-notify

Manual implementation details

BurntToast

Install:

Install-Module BurntToast -Scope CurrentUser

Because Windows PowerShell execution policy initially blocked the module, the current setup uses:

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

Verify:

Import-Module BurntToast

New-BurntToastNotification `
    -Text "Copilot Test", "Toast notifications are working!"

Hook directory

New-Item `
    -ItemType Directory `
    -Path "$HOME\.copilot\hooks" `
    -Force

Files:

$HOME\.copilot\hooks\notification-hooks.json
$HOME\.copilot\hooks\copilot-notify.ps1

If COPILOT_HOME is set, Copilot uses that location instead.


18. Restart behavior

Changes to the hook configuration are loaded when Copilot CLI starts.

Therefore:

notification-hooks.json changed
        |
        v
restart Copilot CLI

Changes to the PowerShell notification script itself are read when the hook fires, so modifying the .ps1 generally does not require a separate hook-config reload.

For predictable testing, restarting Copilot after configuration changes is recommended.


19. Troubleshooting

Toast appears while Copilot tab is visible

Check:

$env:WT_SESSION

Then check the Terminal tab title.

It should follow:

Copilot [XXXXXXXX]

Next run the UI Automation diagnostic and confirm:

TAB: [Copilot [XXXXXXXX]] Selected=True

If the name differs, the wrapper and notification script are no longer using the same identity.


Toast never appears

Verify BurntToast:

Import-Module BurntToast

New-BurntToastNotification `
    -Text "Test", "Toast test"

Then verify the hook configuration is in:

%USERPROFILE%\.copilot\hooks\

and restart Copilot.


Copilot tab title changes unexpectedly

Check Copilot settings:

updateTerminalTitle

It should be:

"updateTerminalTitle": false

If it is true, Copilot may overwrite the stable tab identifier.


WT_SESSION is empty

Run:

$env:WT_SESSION

If it is empty, the command is not running in a Windows Terminal session or the environment has been changed.

The notification script intentionally fails open in this case.


20. Adding more notification events

To add:

shell_completed
shell_detached_completed
agent_idle

add additional entries to the notification array.

For example:

{
  "type": "command",
  "matcher": "shell_completed",
  "powershell": "& \"$env:USERPROFILE\\.copilot\\hooks\\copilot-notify.ps1\" -Title 'Shell completed' -Message 'A background shell command has finished.'",
  "timeoutSec": 5
}

The foreground/background logic does not need to change.

All new notifications automatically pass through:

notification-hooks.json
        |
        v
copilot-notify.ps1
        |
        v
foreground + selected-tab decision

21. Why this architecture is useful

This separates responsibilities cleanly.

Copilot CLI

Responsible for:

What happened?

Hook configuration

Responsible for:

Which events do we care about?

Notification script

Responsible for:

Should the user be interrupted?

BurntToast

Responsible for:

How should Windows display the notification?

This is preferable to trying to make one script understand the entire Copilot lifecycle.


22. Known limitations

This solution is practical rather than an official Copilot/Windows Terminal integration.

Windows Terminal dependency

The exact-tab logic assumes Windows Terminal and its UI Automation tree.

It is not intended to be a generic solution for every terminal emulator.

Stable-title dependency

The notification script intentionally uses the stable title:

Copilot [XXXXXXXX]

If the wrapper is removed or another process changes the tab title, the exact-tab check can fail.

The script is deliberately fail-open, so a detection failure results in a toast rather than silently losing the notification.

UI Automation dependency

UI Automation is a Windows accessibility/automation mechanism rather than a Copilot API.

A future Windows Terminal implementation could change its exposed UI Automation hierarchy, which could require adjusting the script.

Multiple panes

Windows Terminal can host multiple panes within a tab. The current implementation is designed around the Terminal tab level, not detailed pane-level focus management.


23. Rollback

To disable the custom behavior without uninstalling everything:

  1. Remove or rename:
%USERPROFILE%\.copilot\hooks\notification-hooks.json
  1. Restart Copilot.

To restore the old script:

Copy-Item `
    "$HOME\.copilot\hooks\copilot-notify.ps1.bak" `
    "$HOME\.copilot\hooks\copilot-notify.ps1" `
    -Force

To remove BurntToast:

Uninstall-Module BurntToast

The exact Copilot hook files can also simply be deleted if the custom notification system is no longer required.


24. Current end state

The implementation is a working Windows integration and a publicly installable npm package.

Install:

npm install -g copilot-notify

copilot-notify install

Installing the CLI and installing the integration are separate operations.

Remove:

copilot-notify uninstall

npm uninstall -g copilot-notify

The runtime system can be summarized as:

                    GitHub Copilot CLI
                           |
                           |
                  Native hook event
                           |
                           v
               notification-hooks.json
                           |
                           v
                  copilot-notify.ps1
                           |
             +-------------+-------------+
             |                           |
       Win32 foreground             UI Automation
          detection                  Terminal tabs
             |                           |
             +-------------+-------------+
                           |
                           v
                Is exact Copilot tab
                    selected?
                     /     \
                   YES      NO
                    |        |
               suppress     toast
                            |
                            v
                       BurntToast
                            |
                            v
                    Windows desktop toast

This gives Copilot CLI the behavior it currently does not provide as a built-in foreground-aware Windows notification preference: notify only when attention is actually needed. The implementation is distributed as the public copilot-notify npm package so another Windows machine can install the same setup with one command.


25. References

  • GitHub Copilot hooks reference: https://docs.github.com/en/copilot/reference/hooks-reference
  • GitHub Copilot CLI hooks: https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks
  • GitHub Copilot CLI configuration reference: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
  • Microsoft Learn — GetForegroundWindow: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow
  • Microsoft Learn — Windows Terminal command-line arguments and titles: https://learn.microsoft.com/en-us/windows/terminal/command-line-arguments
  • Microsoft Learn — Windows Terminal profile/tab title settings: https://learn.microsoft.com/en-us/windows/terminal/customize-settings/profile-general
  • Microsoft Learn — UI Automation TabItem: https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-supporttabitemcontroltype
  • Microsoft Learn — UI Automation Tab Control: https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-supporttabcontroltype