Skip to content
stage

Notifications

Web Push

The server sends Web Push (VAPID, RFC 8291 aes128gcm) to every subscribed browser. The VAPID key pair is generated on first start and stored in the database; the VAPID subject is STAGE_PUBLIC_URL when it is https, else https://github.com/flexdinesh/stage. Setup: Install as an app.

GET /api/push/config

200 { "publicKey": string } (VAPID public key, base64url, uncompressed P-256 point; the browser's applicationServerKey).

POST /api/push/subscriptions

Body: browser PushSubscription.toJSON() subset { "endpoint": string, "keys": { "p256dh": string, "auth": string } }. Upsert by endpoint (keys and user agent refreshed). 204. 400 missing field or non-https endpoint.

DELETE /api/push/subscriptions

Body { "endpoint": string }. 204 (also when unknown). 400 missing endpoint.

POST /api/push/test

Body { "endpoint": string }. Sends a test notification to that subscription synchronously. 204. 404 unknown endpoint. 502 push service rejected it (error = status and body); a 404/410 from the push service also deletes the subscription.

GET /api/badge

200 { "needsInput": number }: tasks in needs_input + external threads needing input (see Notification rules).

Push payload

JSON, shown by the service worker:

{ "title": "Needs input", "body": "Say OK · stage", "url": "/sessions/thread-abc#answer", "tag": "task-7", "badge": 2 }
  • Needs-input payload URLs end in #answer: the page scrolls to the Needs input panel and focuses its first control.
  • tag replaces an older notification for the same task (task-<id>) or thread (thread-<t3ThreadId>); the service worker sets renotify so a replacement alerts again. iOS ignores tag replacement (WebKit bug 258922), so the service worker also closes shown notifications with the same tag (tag, or the copy in data.tag) before showing the new one.
  • badge = GET /api/badge at send time.
  • TTL 24h; urgency high for needs input, normal otherwise.
  • Header Topic = first 32 chars of base64url(sha256(tag)): the push service keeps only the latest undelivered push per task/thread.

Clear on view

Opening a task page closes its task-<id> system notification and marks group task:<id> read; opening session detail closes its thread-<id> notification and the related task notification when present, and marks those groups read (POST /api/notifications/read with groups). Clicking a notification opens one of these pages. Opening the notifications inbox closes all of them and marks everything read.

Cross-device clear (lazy): every device closes shown task-* / thread-* notifications whose tag is not in GET /api/notifications/unread-tags, i.e. viewed on any device. The service worker does this after showing each push; the app does it on load, when it becomes visible, and on every push. No dismiss pushes are sent: iOS revokes subscriptions for pushes that show no notification, and Chromium/Firefox only tolerate a small budget of them. WebKit ignores close() on notifications younger than ~30s, so those clear at a later sync. Other tags (e.g. test) are never closed by sync.

Service worker

When a window of the app is focused and visible on a Chromium browser, it skips the system notification (the app shows an in-app toast instead); other browsers (Safari, Firefox) always show it, since a push without a notification there can revoke the subscription. Either way it updates the app badge and posts { "type": "push" } to every open window so the app refetches at once.

Notifications inbox

Every notification the notifier sends is stored first (also when no browser is subscribed), so the app can show in-app toasts and an inbox. Entries older than 30 days are pruned hourly.

Notification:

{
  "id": 12,
  "event": "needs_input",
  "taskId": 7,
  "t3ThreadId": "thread-abc",
  "title": "Say OK",
  "projectTitle": "stage",
  "url": "/tasks/7",
  "createdAt": "2026-11-08T10:00:00Z",
  "readAt": null,
  "currentState": "needs_input",
  "pendingQuestionCount": 1,
  "t3DeepLink": "t3code://threads/env-1/thread-abc"
}
  • event: needs_input|done|failed|settled.
  • taskId null for external threads (or once the task is deleted).
  • title / projectTitle are snapshots at send time.
  • currentState is live: the task's state, or for an external thread settled when settled, else its observedState; null when unknown.
  • pendingQuestionCount is live too: answerable questions the task's thread (or the external thread) is waiting on.

Task notifications retain /tasks/<id> in their stored url to preserve grouped identity after restart/deletion. Push payloads and in-app navigation open /sessions/<encoded-thread-id> when a thread exists; tasks without a thread retain the task destination. External entries store their session URL.

GET /api/notifications

Query: limit (default 50, max 200), before (id; only older entries), group (optional group key from the grouped inbox; restricts history to that task/thread).

200 { "notifications": Notification[], "unread": number, "nextBefore": number | null } newest first; nextBefore = the before for the next (older) page, null when none. unread remains the global unread event count. 400 bad limit / before.

GET /api/notifications/groups

Query: limit (default 50, max 200), cursor (opaque nextCursor from the previous page).

200 { "groups": NotificationGroup[], "unread": number, "latestId": number, "nextCursor": string | null }. 400 bad limit / cursor.

NotificationGroup extends Notification with key (stable task/thread identity), eventCount (retained notifications for this group), and unreadCount. Its inherited notification fields describe the latest event; currentState stays live. Group by task across restarts, or by external thread, never by title. Deleted tasks retain their group via their task URL. Identity-less entries remain separate.

Groups whose current state is needs_input come first, then other groups; each section is newest-event-ID first. Aggregation precedes pagination, so repeated events cannot consume a page or cause older copies of a group to reappear. latestId is the global newest event ID captured before listing, independent of priority order; use it for read-on-open, then the newest observed latestId for arrivals while the drawer is open. Events inserted after that boundary remain unread. Unread markers captured on open stay visible until close. The bell still counts unread events.

Expandable history uses GET /api/notifications?group=...; it contains only recorded needs_input, done, failed, and settled notifications, not every lifecycle transition. Unknown current state displays as unavailable rather than falling back to an old event.

POST /api/notifications/read

Body { "upTo": number }: marks every unread entry with id <= upTo read. Or { "groups": string[] } (1 to 20 group keys, e.g. task:12, thread:<t3ThreadId>): marks every unread entry in those groups read. 204. 400 when neither or both are sent, upTo < 1, or groups is empty, too long or has an empty key.

GET /api/notifications/unread-tags

200 { "tags": string[] }: push tags of groups with unread entries (task:<id> → task-<id>, thread:<id> → thread-<id>; identity-less groups have none). Devices close shown notifications whose tag is missing (see Clear on view).

Notification rules

  • Filter: an event not in preferences.notifyEvents (read when the event is sent) is dropped entirely: no inbox row (so no toast or bell entry) and no push, on any device. If the filter cannot be read, the event notifies. See Preferences.
  • Tasks: a transition into needs_input ("Needs input"), done ("Done"), failed ("Failed") or settled ("Settled") notifies. Stored url = /tasks/<id> for stable group identity; push destination = /sessions/<encoded-thread-id> when a thread exists, otherwise the task URL. Other states (incl. interrupted) do not. Links add no events of their own.
  • External threads: non-subagent threads not owned by a task. Every 5s (when T3 is connected) the watcher reads (t3_thread_read) every unsettled root thread with status preparing|queued|starting|running|waiting, plus every watched thread whose observedState is running|needs_input|done (done until settled), and stores the mapped state (State mapping) as observedState. A change into needs_input|done|failed|settled notifies with the same titles; url = /sessions/<encoded-thread-id>. The first pass after startup records states silently; afterwards a thread seen for the first time notifies only when created after startup. A thread T3 no longer knows becomes cancelled silently. Task-owned active sessions also refresh their mirror, even if legacy task state is frozen.
  • External threads needing input (badge, GET /api/threads?needsInput=1): observedState = needs_input, not settled, not subagent, not owned by a task.
  • External running threads (Dashboard Running group, GET /api/threads?running=1): observedState = running, not settled, not subagent, not owned by a task.
  • External settled threads (Dashboard Settled group, GET /api/threads?settledFrom=<iso>): normalized state settled, settledAt >= settledFrom, not subagent, not owned by a task; forks and archived threads included.