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 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({
data: {
terminal_id: '1',
display: '1',
wait_until_display: true,
},
});
await box.notifications.notify.trigger({
data: {
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, display-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:

{
"statusCode": 500,
"error": "Internal Server Error",
"message": "Failed to send notification"
}

…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({
data: {
terminal_id: 'N',
display: 'N',
wait_until_display: true,
},
});
await box.notifications.notify.trigger({ data: { 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(terminal_id), 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.com

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
# All kit commands require container targeting: -c <container-id> (or HOODY_CONTAINER)
hoody notifications trigger -c CONTAINER_ID \
--display "0" \
--summary "Build Complete" \
--body "Your deployment finished successfully"
# Get notification history for display 0
hoody notifications list "0" -c CONTAINER_ID
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 (HTTP 101 Switching Protocols).

const ws = new WebSocket(
'wss://PROJECT_ID-CONTAINER_ID-n-1.SERVER.containers.hoody.com/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. This endpoint is a WebSocket, not a Server-Sent Events stream: the upgrade itself signals connection, and the server pushes JSON heartbeat messages to keep the connection alive (the SDK surfaces these via onHeartbeat). 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.

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
allAll 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 {"statusCode": 500, "error": "Internal Server Error", "message": "Failed to send notification"}. The recovery path is to spawn the display via terminal.sessions.create({ data: { 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. The stream is a WebSocket: the browser handles protocol-level Ping/Pong automatically, and the server also pushes JSON heartbeat messages (surfaced by the SDK via onHeartbeat) you can monitor for liveness.