# Notifications

**Page:** kit/notifications

[Download Raw Markdown](./kit/notifications.md)

---

**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.


This is a **container display notification system**, not a push notification service for mobile or wearable devices. Notifications appear on the container's X11 display (e.g., display `"0"` or `":0"`). To see these notifications, you need access to that display — via a remote desktop session, VNC, or the Hoody browser-based display viewer.


## What You Can Do

- 🖥️ **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

## 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

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.

```typescript
// 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

If `notify.trigger` returns:

```json
{
  "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:

```typescript
// 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

All endpoints accessed relative to your Notification Server URL:
```
https://PROJECT_ID-CONTAINER_ID-n-1.SERVER.containers.hoody.icu
```

**Triggering**:
- [`POST /api/v1/notifications/notify`](/api/kit/notification-server/triggering/) - Send notification to a container display

**Fetching**:
- [`GET /api/v1/notifications/{display}`](/api/kit/notification-server/fetching/) - Get notification history for a display

**Streaming**:
- WebSocket: `wss://.../api/v1/notifications/stream?displays=0,1` (or `displays=all`)

**Icons**:
- [`GET /api/v1/notifications/icons/{iconId}`](/api/kit/notification-server/icons/) - Retrieve notification icon

**Health**:
- [`GET /api/v1/notifications/health`](/api/kit/notification-server/health/) - Service health check

## Send a Notification


  
    ```bash
    # 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
    ```
  
  
    ```typescript
    import { 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 0
    await containerClient.notifications.notify.trigger({
      display: '0',
      summary: 'Build Complete',
      body: 'Your deployment finished successfully',
    });
    ```
  
  
    ```bash
    # Send a notification to display 0
    curl -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 0
    curl "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

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](#notifications-are-attached-to-a-display) for how that happens automatically, and [If notifications fail: start a display manually](#if-notifications-fail-start-a-display-manually) for recovery if ensure cannot obtain a D-Bus session.

## Urgency Levels

**low** - Subtle, dismisses quickly:
```json
{
  "display": "0",
  "summary": "Background task finished",
  "urgency": "low",
  "expire_time": 3000
}
```

**normal** - Standard notification:
```json
{
  "display": "0",
  "summary": "Build Complete",
  "urgency": "normal"
}
```

**critical** - Highest urgency; often remains visible until dismissed, depending on daemon policy:
```json
{
  "display": "0",
  "summary": "System Alert",
  "body": "Immediate action required",
  "urgency": "critical",
  "expire_time": 0
}
```

## 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).

```javascript
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](/api/kit/notification-server/streaming/) for the full message shapes per transport.

### 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:

```javascript
// 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.

## Notification History

Query past notifications:

```bash
# 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"
```

## Use Cases

### CI/CD Pipeline Alerts
Long-running build completes → HTTP POST → notification appears on the container display in a running display session.

### System Monitoring
Server alerts → HTTP → desktop notification on the container's X11 display, visible in any active display session.

### Long-Running Task Completion
Data exports, video rendering, ML model training, backup completion — notify when done without polling.

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

## Best Practices

### 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
Write clear, actionable summaries, include relevant context in body, set appropriate urgency levels, do not spam.

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

## 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](#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

### 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](#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
**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
**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.

## What's Next