Blog

Getting Desktop Notifications From Codex on Linux, Windows, and WSL

Nov 20, 2025 12 min read development

Wire Codex into native desktop notifications on Linux, Windows, and WSL with lightweight scripts.

Letting Codex grind through a long refactor or multi step change while you watch YouTube is nice. Realizing 20 minutes later that it was done after 30 seconds is not.

This guide shows how to wire Codex into native desktop notifications on:

  • Linux / Unix (notify send)
  • Windows (Python + win10toast)
  • WSL (Codex in WSL, notifications in Windows via PowerShell)

All three variants use the same basic mechanism: Codex calls a script on every completed turn, passes a JSON payload, and the script triggers a desktop notification.

VS Code with Codex panel and a Codex notification toast


Where this integrates nicely with your workflow

Once Codex can nudge you through the OS notification system, several patterns open up:

  • Use Codex to perform codebase wide changes while you are reviewing merge requests in the browser.

  • Let Codex run integration tests, fix failures and report back with “ready to review” notifications.

  • Combine it with a task manager like Kanman to track larger refactors:

    • Create a Kanman task for “Migrate FooService to new API”.
    • Let Codex execute the mechanical steps.
    • Each completed step triggers a notification, and you can tick off subtasks in Kanman as you go.

The notification bridge solves the attention problem: Codex does the heavy lifting, your desktop tells you when it is time to come back and make the next decision.


How Codex notifications work

Codex reads a config.toml file in ~/.codex (Linux / WSL) or C:\Users\<user>\.codex (Windows).

The Codex docs on notifications explain the JSON payload and the notify setting, but they stop at the interface: you still have to supply an OS-specific script, and some setups (WSL, minimal Linux desktops) do not ship anything that can show a toast out of the box.

There is a single key that matters here:

notify = ["command", "arg1", "arg2"]

Codex runs this command whenever it wants to raise a notification and appends one final argument: a JSON string describing the event.

Typical payload:

{
  "type": "agent-turn-complete",
  "last-assistant-message": "I have finished applying the changes.",
  "input-messages": [
    "Refactor the FooService to use dependency injection.",
    "Update all call sites accordingly."
  ]
}

Important points:

  • type is agent-turn-complete when Codex finishes a turn and is waiting on you again.
  • last-assistant-message is the last message Codex produced.
  • input-messages is what you asked it to do.

In realtiy, Codex injects a lot of context into input-messages (open tabs, working directory, active file, etc.) and often uses Markdown lists in last-assistant-message:

{
  "type": "agent-turn-complete",
  "thread-id": "019aa2d1-f501-7760-9354-afb6d88317e9",
  "turn-id": "0",
  "cwd": "/code/project",
  "input-messages": [
    "# Context from my IDE setup:\n\n## Active file: /home/<user>/.codex/config.toml\n\n## Open tabs:\n- config.toml: /home/<user>/.codex/config.toml\n- blog.html: themes/kanman/layouts/partials/sections/blog.html\n- hero.yaml: data/home/de/hero.yaml\n- blog.yaml: data/home/de/blog.yaml\n- styles.css: themes/kanman/assets/css/styles.css\n\n## My request for Codex:\nPlease revisit the product box in the sidebar of the blog post layout. Use the text content of the feature section and the hero section to come up with an actual meaningful pitch.\n"
  ],
  "last-assistant-message": "- Refreshed the blog post sidebar pitch in `themes/kanman/layouts/_default/single.html` to mirror the hero/features messaging: project-first, no KPI/gamification noise, drag-and-drop Priorisierung, and no calendar/AI baggage.\n- Swapped CTA copy to “Kanman jetzt nutzen,” added a supporting bullet list with real strengths, and included the lifetime pricing subline from the hero text.\n\nTests not run (copy-only change). Next step: preview the blog post page to ensure the new sidebar layout/line breaks feel right."
}

Unfiltered, that would be a wall of text in a toast. The scripts below trim the request down to a short title, pull only the bullet points from the assistant response, and truncate each line so your notification stays readable. You can extend that idea to:

  • Strip the “Context from my IDE” block entirely if you do not want path leaks.
  • Keep the Markdown bullets (useful to see multiple actions Codex took) or collapse them into a single sentence.
  • Route different type values to different notification channels (e.g. errors vs. completions).

You only need to:

  1. Implement a small script that:

    • reads argv[1],
    • parses the JSON,
    • decides whether to notify,
    • fires a desktop notification.
  2. Point notify in config.toml at that script.

The rest of this post is just three concrete scripts for three environments.


Use cases: why bother at all

This wiring is useful whenever Codex is doing something non trivial:

  • Large multi file refactors.
  • Code generation followed by test runs and fixes.
  • Migration work across services or modules.
  • Any workflow where Codex says things like “I need your approval to apply these changes”.

Instead of staring at a progress log, you can:

  • Switch to another IDE window.
  • Work on documentation.
  • Or yes, watch YouTube.

The notification brings you back when Codex either finished or needs input again.


Variant 1: Linux / Unix with notify-send

This assumes:

  • You run Codex inside a normal Linux desktop session.
  • notify-send is available (libnotify-bin on Debian / Ubuntu).

Install dependencies

On Debian / Ubuntu:

sudo apt update
sudo apt install -y libnotify-bin python3

Notification script

Create ~/.codex/notify.py:

#!/usr/bin/env python3
import json
import re
import subprocess
import sys


def trunc(text: str, limit: int) -> str:
    """Truncate at a word boundary and add ellipsis when needed."""
    if len(text) <= limit:
        return text
    cut = max(0, limit - 3)
    head = text[:cut]
    head = re.sub(r"\s+\S*$", "", head)
    if not head:
        head = text[:cut]
    return f"{head}..."

def title_from_request(inputs: list[str]) -> str:
    """Use the 'My request for Codex' block as title, else fall back."""
    block = "\n".join(inputs or [])
    lines = block.splitlines()
    capture = False
    picked: list[str] = []
    for line in lines:
        if capture:
            picked.append(line)
        if line.startswith("## My request for Codex:"):
            capture = True
            picked.append(line.replace("## My request for Codex:", "", 1).strip())
    title = " ".join(" ".join(picked).split()).strip()
    return trunc(title or "Codex chat", 120)


def body_from_assistant(text: str) -> str:
    """Prefer Markdown bullets; otherwise condense to one line."""
    bullets: list[str] = []
    for line in text.splitlines():
        if re.match(r"^\s*[-*]\s+", line):
            bullets.append("- " + re.sub(r"^\s*[-*]\s+", "", line))
    if bullets:
        return "\n".join(trunc(b, 80) for b in bullets)
    one_line = " ".join(text.split()).strip()
    return trunc(one_line or "(no assistant message)", 220)


def main() -> int:
    if len(sys.argv) < 2:
        return 0

    try:
        payload = json.loads(sys.argv[1])
    except json.JSONDecodeError:
        return 0

    if payload.get("type") != "agent-turn-complete":
        return 0

    assistant = (
        payload.get("last-assistant-message")
        or payload.get("last_assistant_message")
        or ""
    )
    title = title_from_request(payload.get("input-messages") or [])
    message = body_from_assistant(assistant)

    try:
        subprocess.run(["notify-send", title, message], check=False)
    except Exception:
        # Never break Codex on notification failure
        pass

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

What it does (same flow as the WSL and Windows variants below):

  • Drops any payload that is not agent-turn-complete.
  • Builds the title from the “My request for Codex” section (or falls back to “Codex chat”) and truncates it.
  • Pulls Markdown bullets from the assistant reply for the body (else uses a single condensed line), truncating each line.
  • Sends the cleaned title/body to notify-send and swallows errors.

Make it executable:

chmod +x ~/.codex/notify.py

Codex config on Linux

Edit ~/.codex/config.toml and make sure notify is a top level key near the top of the file:

model = "gpt-5.1-codex-max"

notify = ["python3", "/home/your-user/.codex/notify.py"]

[history]
persistence = "save-all"

[tui]
notifications = ["agent-turn-complete"]

Two critical details:

  • Use your actual home path.
  • notify must be a root key, not inside [tui] or any other table.

Quick end to end test

In a terminal:

python3 ~/.codex/notify.py \
'{"type":"agent-turn-complete","last-assistant-message":"Linux notification test","input-messages":["Example task"]}'

You should see a native notification. If that works, start Codex, give it a non trivial task, switch away, and wait for the toast.


Variant 2: Windows notifications with win10toast

On Windows, the easiest path is Python plus the win10toast library. No PowerShell modules, no registry hacks.

This variant assumes:

  • Codex runs on Windows, not inside WSL.
  • Your config.toml lives in C:\Users\<user>\.codex\config.toml.

Install Python and win10toast

Install Python if you have not already, then in a terminal or PowerShell:

py -m pip install win10toast

(or python -m pip install win10toast depending on your setup).

Notification script on Windows

Create this file:

C:\Users\<user>\.codex\notify.py

#!/usr/bin/env python
import json
import re
import sys
from win10toast import ToastNotifier


def trunc(text: str, limit: int) -> str:
    """Truncate at a word boundary and add ellipsis when needed."""
    if len(text) <= limit:
        return text
    cut = max(0, limit - 3)
    head = text[:cut]
    head = re.sub(r"\s+\S*$", "", head)
    if not head:
        head = text[:cut]
    return f"{head}..."


def title_from_request(inputs: list[str]) -> str:
    """Use the 'My request for Codex' block as title, else fall back."""
    block = "\n".join(inputs or [])
    lines = block.splitlines()
    capture = False
    picked: list[str] = []
    for line in lines:
        if capture:
            picked.append(line)
        if line.startswith("## My request for Codex:"):
            capture = True
            picked.append(line.replace("## My request for Codex:", "", 1).strip())
    title = " ".join(" ".join(picked).split()).strip()
    return trunc(title or "Codex chat", 120)


def body_from_assistant(text: str) -> str:
    """Prefer Markdown bullets; otherwise condense to one line."""
    bullets: list[str] = []
    for line in text.splitlines():
        if re.match(r"^\s*[-*]\s+", line):
            bullets.append("- " + re.sub(r"^\s*[-*]\s+", "", line))
    if bullets:
        return "\n".join(trunc(b, 80) for b in bullets)
    one_line = " ".join(text.split()).strip()
    return trunc(one_line or "(no assistant message)", 220)


def main() -> int:
    if len(sys.argv) < 2:
        return 0

    try:
        payload = json.loads(sys.argv[1])
    except json.JSONDecodeError:
        return 0

    if payload.get("type") != "agent-turn-complete":
        return 0

    assistant = (
        payload.get("last-assistant-message")
        or payload.get("last_assistant_message")
        or ""
    )
    title = title_from_request(payload.get("input-messages") or [])
    message = body_from_assistant(assistant)

    try:
        toaster = ToastNotifier()
        toaster.show_toast(
            title,
            message,
            duration=5,
            threaded=True,
        )
    except Exception:
        pass

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Same behavior as the Linux/WSL variants:

  • Only reacts to agent-turn-complete.
  • Title comes from “My request for Codex,” truncated; fallback is “Codex chat”.
  • Body prefers Markdown bullets from the assistant reply; otherwise uses one condensed line, truncating each part.
  • Errors are swallowed so Codex keeps running.

Codex config on Windows

Edit C:\Users\<user>\.codex\config.toml:

model = "gpt-5.1-codex-max"

notify = ["py", "C:/Users/your-user/.codex/notify.py"]

[history]
persistence = "save-all"

[tui]
notifications = ["agent-turn-complete"]

Adjust the interpreter (py, python, python.exe) and path to match your system.

Test

From PowerShell:

py C:/Users/your-user/.codex/notify.py `
'{"type":"agent-turn-complete","last-assistant-message":"Windows notification test","input-messages":["Example task"]}'

If you see a Windows toast, Codex can now ping you whenever a turn completes.


Variant 3: Codex in WSL, notifications in Windows

This is the interesting one.

Scenario:

  • You run Codex inside WSL because your toolchain lives there.
  • WSL has no GUI or notification daemon.
  • You still want native Windows notifications while Codex runs in the background.

The solution:

  • notify in config.toml points to a Bash script in WSL.
  • That script receives the JSON payload and forwards it to Windows.
  • The Bash script calls powershell.exe directly; no extra .ps1 file on Windows.
  • Windows PowerShell uses the BurntToast module to fire a native toast.

You get the best of both worlds: Linux toolchain, Windows notifications.

Install BurntToast on Windows

Open Windows PowerShell (Windows PowerShell 5, not PowerShell 7):

Install-Module -Name BurntToast -Scope CurrentUser -Force

If NuGet is missing, install it when prompted.

Quick sanity check:

Import-Module BurntToast
New-BurntToastNotification -Text 'Codex', 'Direct BurntToast test'

You should see a toast.

Bash wrapper in WSL (calls PowerShell directly)

Install jq if missing:

sudo apt-get install -y jq

Create ~/.codex/notify.sh:

#!/usr/bin/env bash
set -euo pipefail

payload="${1:-}"

type=$(jq -r '.type // empty' <<<"$payload" 2>/dev/null)
[ "$type" != "agent-turn-complete" ] && exit 0

# truncate to max chars, cut back to last full word, add "..." if truncated
trunc() {
  local s="$1" n="$2"
  [ ${#s} -le $n ] && { printf '%s' "$s"; return; }
  local cut=$((n-3))
  local head
  head=$(printf '%s' "${s:0:$cut}" | sed -E 's/[[:space:]]+[^[:space:]]*$//')
  [ -z "$head" ] && head="${s:0:$cut}"
  printf '%s...' "$head"
}

# ---- title: extract after "## My request for Codex:"
title=$(jq -r '."input-messages"[]? // ""' <<<"$payload" 2>/dev/null \
  | awk 'f{print} /^## My request for Codex:/{f=1; sub(/^## My request for Codex:[[:space:]]*/,""); print}' \
  | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/^[[:space:]]+|[[:space:]]+$//g')

[ -z "$title" ] && title="Codex chat"
title=$(trunc "$title" 120)

# ---- body: bullets if present, else full assistant msg
assistant=$(jq -r '."last-assistant-message" // .last_assistant_message // ""' <<<"$payload" 2>/dev/null)

bullets=$(printf '%s\n' "$assistant" | grep -E '^[[:space:]]*[-*][[:space:]]+' || true)
if [ -n "$bullets" ]; then
  message=$(printf '%s\n' "$bullets" \
    | sed -E 's/^[[:space:]]*[-*][[:space:]]+/- /' \
    | while IFS= read -r l; do trunc "$l" 80; echo; done)
else
  one_line=$(printf '%s' "$assistant" | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/^[[:space:]]+|[[:space:]]+$//g')
  message=$(trunc "${one_line:-"(no assistant message)"}" 220)
fi

title_esc=${title//\'/\'\'}
message_esc=${message//\'/\'\'}

powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass \
  -Command "Import-Module BurntToast; New-BurntToastNotification -Text '$title_esc', '$message_esc' -AppLogo '\\\\wsl.localhost\\Ubuntu-22.04\\home\\<your-user>\\.codex\\codex-logo.png'"

What happens in this Bash wrapper:

  • Exits unless the payload type is agent-turn-complete.
  • Pulls the title from the “My request for Codex” section, trims whitespace, and truncates to 120 chars at word boundaries (fallback: “Codex chat”).
  • Prefers Markdown bullets from the assistant reply for the body (each truncated to 80 chars); if none exist, condenses the entire reply to 220 chars.
  • Escapes single quotes for PowerShell and calls New-BurntToastNotification directly from WSL, including an icon (adjust the UNC path to your distro/user or drop -AppLogo).

Make it executable:

chmod +x ~/.codex/notify.sh

Adjust the UNC path inside -AppLogo to match your distro and user. If you do not want an icon, drop that flag.

Codex config in WSL

Edit /home/your-user/.codex/config.toml inside WSL:

model = "gpt-5.1-codex-max"

notify = ["/bin/bash", "/home/your-user/.codex/notify.sh"]

[history]
persistence = "save-all"

[tui]
notifications = ["agent-turn-complete"]

Make sure:

  • This config.toml is the one the WSL Codex installation uses.
  • notify is at the top level, before any [table].

Test from WSL

Run:

~/.codex/notify.sh \
'{"type":"agent-turn-complete","last-assistant-message":"WSL test notification","input-messages":["Example task in WSL"]}'

If you see a Windows toast, the bridge works.

Now start Codex from WSL (or from VS Code attached to WSL), queue a longer task, switch away, and wait for the notification.

Optional: use an icon

Point the -AppLogo path in ~/.codex/notify.sh to a file Windows can read. Use a UNC path into WSL (\\\\wsl.localhost\\<distro>\\home\\<user>\\.codex\\codex-logo.png) or a straight Windows path (C:\\Users\\your-user\\Pictures\\codex-logo.png). Remove the flag entirely if you want a plain toast.

The rest of the WSL setup stays unchanged.

Summary

The core ideas are simple:

  • Codex already knows how to call an external command on each completed turn.
  • Every desktop platform has a way to show notifications from scripts.
  • Gluing those together is a matter of a few lines of Python, Bash or PowerShell.

Linux users wire notify to a Python script that wraps notify-send. Windows users point notify at a small Python script using win10toast. WSL users run a single Bash wrapper that filters the JSON and calls powershell.exe with BurntToast directly - no extra .ps1 file.

Once set up, Codex becomes something you can safely leave running in the background, while focusing on the next item on your Kanman board instead of staring at a log window.

Marco Kerwitz
Author

Marco Kerwitz

Founder of kanman.de

Why kanman

Screw plans. Screw perfection. kanman keeps your started projects in focus and skips KPI and gamification fluff.

  • Started projects always stay front and center without dashboard overload.
  • Prioritize with drag and drop; tasks follow along automatically.
  • No calendars, no KPIs, no AI telling you what to do.
Use kanman now

No subscription, €49 for lifetime access.