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.
What You Can Do
Section titled “What You Can Do”- 🖥️ Container Display Notifications — Send Linux desktop notifications via
notify-sendon 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
How It Works
Section titled “How It Works”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.
Notifications are attached to a display
Section titled “Notifications are attached to a display”Every notification you send is routed to a specific X11 display inside the container. The flow is:
- The kit’s HTTP handler calls
notify-send, which speaks D-Bus to thedunstdaemon running on the target display. dunstruns a logging hook that records each delivered notification to a per-display history JSON file in the kit’s notification-history directory.- 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
displayargument — Hoody Terminal boots the X server anddunstthe 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.
API Endpoints Summary
Section titled “API Endpoints Summary”All endpoints accessed relative to your Notification Server URL:
https://PROJECT_ID-CONTAINER_ID-n-1.SERVER.containers.hoody.icuTriggering:
POST /api/v1/notifications/notify- Send notification to a container display
Fetching:
GET /api/v1/notifications/{display}- Get notification history for a display
Streaming:
- WebSocket:
wss://.../api/v1/notifications/stream?displays=0,1(ordisplays=all)
Icons:
GET /api/v1/notifications/icons/{iconId}- Retrieve notification icon
Health:
GET /api/v1/notifications/health- Service health check
Send a Notification
Section titled “Send a Notification”# Send a notification to display 0hoody notifications trigger \ --display "0" \ --summary "Build Complete" \ --body "Your deployment finished successfully"
# Get notification history for display 0hoody notifications list "0" --limit 50import { HoodyClient } from '@hoody-ai/hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.icu', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Send notification to display 0await containerClient.notifications.notify.trigger({ display: '0', summary: 'Build Complete', body: 'Your deployment finished successfully',});# Send a notification to display 0curl -X POST "https://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.icu/api/v1/notifications/notify" \ -H "Content-Type: application/json" \ -d '{ "display": "0", "summary": "Build Complete", "body": "Your deployment finished successfully" }'
# Get notification history for display 0curl "https://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.icu/api/v1/notifications/0?limit=50" The notification appears on the container’s X11 display 0 — visible in any connected display session.
Display Parameter
Section titled “Display Parameter”The display parameter specifies which X11 display to target inside the container:
| Value | Meaning |
|---|---|
"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.
Urgency Levels
Section titled “Urgency Levels”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}Real-Time WebSocket
Section titled “Real-Time WebSocket”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= value | Behavior |
|---|---|
0 | Only notifications routed to display 0 |
0,1,3 | Notifications 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 themnew WebSocket('wss://…/api/v1/notifications/stream?displays=10');
// User B's tab — only sees their ownnew 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.
Notification History
Section titled “Notification History”Query past notifications:
# Last 50 notifications on display 0curl "https://.../api/v1/notifications/0?limit=50"
# Time rangecurl "https://.../api/v1/notifications/0?since=1749025000000"
# Multiple displayscurl "https://.../api/v1/notifications/0,1,2?limit=100"Use Cases
Section titled “Use Cases”CI/CD Pipeline Alerts
Section titled “CI/CD Pipeline Alerts”Long-running build completes → HTTP POST → notification appears on the container display in a running display session.
System Monitoring
Section titled “System Monitoring”Server alerts → HTTP → desktop notification on the container’s X11 display, visible in any active display session.
Long-Running Task Completion
Section titled “Long-Running Task Completion”Data exports, video rendering, ML model training, backup completion — notify when done without polling.
Automated Workflow Events
Section titled “Automated Workflow Events”Cron jobs, scheduled tasks, and automation scripts can send notifications to the container display upon completion or failure.
Best Practices
Section titled “Best Practices”Display ID Conventions
Section titled “Display ID Conventions”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.
Notification Quality
Section titled “Notification Quality”Write clear, actionable summaries, include relevant context in body, set appropriate urgency levels, do not spam.
WebSocket for Dashboards
Section titled “WebSocket for Dashboards”Subscribe to relevant displays only, implement reconnection logic, filter notifications client-side if needed, show notification history in UI.
Useful Questions
Section titled “Useful Questions”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.
Troubleshooting
Section titled “Troubleshooting”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.
WebSocket Disconnects
Section titled “WebSocket Disconnects”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.