Skip to content

Send Linux desktop notifications to container displays via HTTP. The Notification Server runs inside your container and dispatches notifications using notify-send on the container’s X11 display — the same mechanism used by Linux desktop notification daemons.

  • 🖥️ Container Display Notifications — Send Linux desktop notifications via notify-send on a container’s X11 display
  • 📡 WebSocket Streaming — Real-time notification feed
  • 📜 Notification History — Query past notifications with filtering
  • 🎨 Custom Icons — Include images in notifications
  • Urgency Levels — low, normal, critical priority
  • ⏱️ Auto-Expiration — Notifications auto-dismiss after timeout
  • 🏷️ Categorization — Organize and filter by category

The Notification Server receives HTTP POST requests and calls notify-send on the specified container display. The display parameter maps to an X11 display identifier — for example "0" (equivalent to :0) or ":1".

Notifications appear wherever that display is rendered: in a Hoody display viewer session, a VNC session, or any remote desktop client connected to the container.

Every notification you send is routed to a specific X11 display inside the container. The flow is:

  1. The kit’s HTTP handler calls notify-send, which speaks D-Bus to the dunst daemon running on the target display.
  2. dunst runs a logging hook that records each delivered notification to a per-display history JSON file in the kit’s notification-history directory.
  3. The kit’s file watcher (inotify on that directory) picks up the new entry and fans it out to any WebSocket / SSE subscribers.

You do not need to set up Xvfb, D-Bus, or dunst yourself. A display’s services come up in two ways:

  • When you create a terminal/desktop session with a display argument — Hoody Terminal boots the X server and dunst the first time a session for that display is created.
  • On-demand, when a notification is triggered — before dispatching, the notifications kit calls Hoody Terminal to ensure the target display exists (display-ensure, enabled by default). A first notification to a not-yet-running display normally succeeds once the display boots; if ensure cannot return a D-Bus address — and no inherited D-Bus session is available — within the display-ensure timeout (30s by default), the trigger returns HTTP 500 instead.
// Creating a terminal session with `display: '1'` is enough — when this
// returns, X server + dunst should be ready on :1, so notifications to
// display "1" do not need a separate manual display start.
await box.terminal.sessions.create({
terminal_id: '1',
display: '1',
wait_until_display: true,
});
await box.notifications.notify.trigger({
display: '1',
summary: 'Build complete',
body: 'Your deployment finished successfully',
});

The same is true when you open a display via the URL path (terminal-N?display=N&redirect=display, desktop-N, etc.) — every entry point that brings a display up also brings up its notification daemon.

If notifications fail: start a display manually

Section titled “If notifications fail: start a display manually”

If notify.trigger returns:

{
"success": false,
"error": "Notification dispatch failed",
"details": "No D-Bus session available for display :N. hoody-terminal ensure did not provide dbus_address."
}

…then the kit’s automatic display-ensure could not obtain a working D-Bus session for :N — Hoody Terminal either could not bring the display up or did not return a dbus_address. Spawn the display explicitly via the SDK and retry:

// Recovery: bring up display :N (boots Xvfb + dunst) explicitly, then retry.
await box.terminal.sessions.create({
terminal_id: 'N',
display: 'N',
wait_until_display: true,
});
await box.notifications.notify.trigger({ display: 'N', summary: '' });

Keeping the session alive is also a latency optimization: after a successful ensure, repeated triggers reuse the display-ensure cache for 60 seconds; once the cache expires, ensure runs again and is usually quick for an already-running display. If you tear the session down with terminal.sessions.delete(terminalId), the next trigger may first try a cached D-Bus address, then invalidate it after a notify-send failure and re-run ensure — it can still fail if ensure cannot produce a D-Bus session or notify-send keeps failing before the retry deadline.

All endpoints accessed relative to your Notification Server URL:

https://PROJECT_ID-CONTAINER_ID-n-1.SERVER.containers.hoody.icu

Triggering:

Fetching:

Streaming:

  • WebSocket: wss://.../api/v1/notifications/stream?displays=0,1 (or displays=all)

Icons:

Health:

Terminal window
# Send a notification to display 0
hoody notifications trigger \
--display "0" \
--summary "Build Complete" \
--body "Your deployment finished successfully"
# Get notification history for display 0
hoody notifications list "0" --limit 50
POST Send a notification to display 0
/api/v1/notifications/notify
Click "Run" to execute the request

The notification appears on the container’s X11 display 0 — visible in any connected display session.

The display parameter specifies which X11 display to target inside the container:

ValueMeaning
"0"Display :0 (most common default)
":0"Same as above, explicit X11 format
"1"Display :1
":1"Same as above, explicit X11 format

The display field is always a JSON string, even when the value is a number ("0", not 0). It must be a syntactically valid X11 display ID — digits, optionally colon-prefixed ("0", ":1", "10"); it does not have to be running before the request — the kit ensures it on demand. See Notifications are attached to a display for how that happens automatically, and If notifications fail: start a display manually for recovery if ensure cannot obtain a D-Bus session.

low - Subtle, dismisses quickly:

{
"display": "0",
"summary": "Background task finished",
"urgency": "low",
"expire_time": 3000
}

normal - Standard notification:

{
"display": "0",
"summary": "Build Complete",
"urgency": "normal"
}

critical - Highest urgency; often remains visible until dismissed, depending on daemon policy:

{
"display": "0",
"summary": "System Alert",
"body": "Immediate action required",
"urgency": "critical",
"expire_time": 0
}

Monitor notifications in real-time. The stream endpoint upgrades to a WebSocket when the client requests it (and falls back to Server-Sent Events otherwise).

const ws = new WebSocket(
'wss://PROJECT_ID-CONTAINER_ID-n-1.SERVER.containers.hoody.icu/api/v1/notifications/stream?displays=0'
);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'notification') {
// msg = { type: 'notification', display: '0',
// data: { id, display_id, appname, summary, body, urgency, … } }
console.log('New notification on display', msg.display, msg.data.summary);
}
};

The stream sends notification messages — one per emitted notification. On the SSE transport you also get a connected message on open and a periodic heartbeat; an invalid SSE request fails with HTTP 400 before the stream opens. On WebSocket the upgrade itself signals connection and keep-alives are protocol-level Ping/Pong (no JSON heartbeat) — but the server can still push a JSON error message for an invalid initial display id, a rejected origin, or a connection/rate limit. See the streaming reference for the full message shapes per transport.

Multiple displays — per-display isolation

Section titled “Multiple displays — per-display isolation”

The displays query parameter chooses which displays the subscription receives:

displays= valueBehavior
0Only notifications routed to display 0
0,1,3Notifications routed to any of displays 0, 1, or 3
all (or *)All displays — useful for debug subscribers and dashboards

Per-display subscriptions are filtered server-side — a subscriber to display 0 will never receive a notification triggered on display 1, even if both are active in the same container. This lets you put one tab per user on the same container without cross-talk:

// User A's tab — only sees notifications meant for them
new WebSocket('wss://…/api/v1/notifications/stream?displays=10');
// User B's tab — only sees their own
new WebSocket('wss://…/api/v1/notifications/stream?displays=20');

Subscribing to a display does not start that display — only trigger requests ensure displays on demand. Pre-creating sessions with parallel terminal.sessions.create calls is optional: do it when you want lower first-notification latency, or visible desktops ready before the first trigger.

Query past notifications:

Terminal window
# Last 50 notifications on display 0
curl "https://.../api/v1/notifications/0?limit=50"
# Time range
curl "https://.../api/v1/notifications/0?since=1749025000000"
# Multiple displays
curl "https://.../api/v1/notifications/0,1,2?limit=100"

Long-running build completes → HTTP POST → notification appears on the container display in a running display session.

Server alerts → HTTP → desktop notification on the container’s X11 display, visible in any active display session.

Data exports, video rendering, ML model training, backup completion — notify when done without polling.

Cron jobs, scheduled tasks, and automation scripts can send notifications to the container display upon completion or failure.

Use consistent display IDs (per-user, per-tenant, per-purpose) and document your mapping. For latency-sensitive paths, keep a terminal/desktop session alive for displays you send to often; otherwise the notifications kit ensures the display on demand when a trigger arrives.

Write clear, actionable summaries, include relevant context in body, set appropriate urgency levels, do not spam.

Subscribe to relevant displays only, implement reconnection logic, filter notifications client-side if needed, show notification history in UI.

Q: How do I view the notifications? Notifications appear on the container’s X11 display. Access the display via a Hoody display viewer session, VNC, or any remote desktop client connected to the container.

Q: Can I send notifications to my phone? No — this system uses Linux notify-send on a container display, not a mobile push notification service. Notifications go to the container’s X11 display environment, not to mobile devices.

Q: What is the display parameter? It’s an X11 display identifier (e.g., "0" for display :0). It must be syntactically valid (digits, optionally colon-prefixed); the kit ensures that display before sending. If ensure cannot obtain a D-Bus session, or notify-send still fails before the retry deadline, the trigger returns HTTP 500.

Q: What if I send to a display that doesn’t exist? The kit first tries to bring the display up automatically (display-ensure). A non-existent display only becomes an error if ensure cannot provide a usable D-Bus session, or notify-send still fails before the retry deadline — typically HTTP 500 with {"success": false, "error": "Notification dispatch failed", "details": "No D-Bus session available for display :N…"}. The recovery path is to spawn the display via terminal.sessions.create({ terminal_id, display, wait_until_display: true }), then retry. See If notifications fail: start a display manually. (Stream subscribers to a non-running display just receive nothing — they do not error.)

Q: Can I use this without an X11 display? No. The kit ultimately calls notify-send, which speaks the freedesktop D-Bus notification protocol — so an X11 display and a notification daemon (dunst) must be running. Both are started for you automatically — either when you create a terminal/desktop session with a display argument, or on-demand when the kit ensures the display before a trigger; you do not install or run them yourself.

Q: Does this work offline? Usually — the notification server runs inside your container, so delivery does not require internet access once the container, display services, D-Bus, and the notify-send path are all working.

Notification Not Appearing (HTTP 500 “No D-Bus session available”)

Section titled “Notification Not Appearing (HTTP 500 “No D-Bus session available”)”

Cause: The kit’s automatic display-ensure could not obtain a working D-Bus session for the target display — Hoody Terminal could not bring the display up, or returned no dbus_address, so notify-send had no session to dispatch through. Solution: Spawn the display explicitly via the SDK and retry — see If notifications fail: start a display manually. Once the terminal session is created with wait_until_display: true, subsequent triggers should not need another manual display start; failures can still occur if D-Bus or notify-send fails before the retry deadline.

Notification triggered but never arrives on the WebSocket

Section titled “Notification triggered but never arrives on the WebSocket”

Cause: Subscriber is filtering for a different display (per-display isolation) or the trigger went to a display that’s down. Solution: Confirm the displays= query parameter on the WebSocket matches the display field in the trigger. To receive everything for debugging, subscribe with displays=all.

Cause: Network instability or idle timeout. Solution: Implement reconnection with exponential backoff and handle connection errors gracefully. On the SSE transport you can also watch for the periodic JSON heartbeat message; on WebSocket the browser handles protocol-level Ping/Pong automatically — there is no JSON heartbeat to monitor.