Guides
Notifications
Reach a person who isn't watching - permission requests, finished turns, errors and closes, POSTed to a webhook.
A session that needs an approval is useless if nobody is looking at it. The session WebSocket is the live channel, but it only helps someone with a socket open - and a phone cannot hold one in the background. Session notifications are the way out: the server reaches you.
Four moments, chosen because they are the ones a person acts on:
| Type | When |
|---|---|
permission_requested |
The agent is blocked on an approval. |
turn_completed |
A turn finished; the session is idle and waiting. |
session_error |
The session failed. |
session_closed |
The session ended, whoever ended it. |
This is a human-attention channel, not an event mirror. Everything else stays on the session WS
- attach with
afterSeqto catch up on what you missed.
Enabling
const worker = createWorkerServer({
authenticate,
notifications: {
webhook: {
url: 'https://my-app.test/hooks/session',
headers: { authorization: '…' },
events: ['permission_requested', 'session_error'], // default: all four
},
// In-process seam, unfiltered - fires whether or not a webhook is configured.
onNotification: (n) => console.log(n.type, n.sessionId, n.preview),
},
})
The config is server-wide, unlike the job queue’s
per-job webhook - the whole point is hearing about sessions you did not create and are not
attached to. Every registry session qualifies, job runs included, so a job carrying its own
webhook is reported on both channels.
The payload
{
"type": "permission_requested",
"sessionId": "sess_…",
"seq": 42, // attach with afterSeq: 41 to land on the event behind it
"ts": 1767225600000,
"preview": "Bash", // one line fit for a notification body
"session": { /* SessionInfo as the event left it - status, title, cwd, cost */ },
"request": { /* the full PermissionRequest */ }
}
request rides along on permission_requested for one reason: with the request id in hand a
consumer can answer over REST -
POST /v1/sessions/:sessionId/permissions/:requestId
{ "behavior": "allow" }
- which is what makes an Approve/Deny action on a lock-screen notification, or a button in a Slack message, work at all. See Permissions.
turn_completed carries result (isError, durationMs, numTurns, totalCostUsd);
session_closed carries reason.
Delivery
Ordered per session, best-effort, retried with exponential backoff (attempts, default 3;
retryDelayMs, default 500). A consumer that missed one can always attach to the session WS and
read the truth - deliveries are a prompt, never the system of record.
Push notifications
The server holds no push credentials, by design: it speaks HTTP to a URL you control and knows nothing about APNs, FCM, Slack or email. Turning a notification into a phone push is a forwarder’s job - it needs credentials, and those belong to whatever you run in front of, or alongside, the gateway.
The workerdeck CLI ships one such forwarder for APNs, used by the iOS app. Two rules there are
worth knowing because they shape what a phone actually buzzes about:
- The allowlist is per device, not per gateway. Each device registers which of the four types
it wants; the forwarder sends nothing outside that list. The default is every type except
session_closed, which fires whenever a session goes away and is mostly noise across several open sessions. A phone and an iPad watching the same gateway can choose differently. - Every push collapses per session, per kind. A session with five tool calls waiting holds one banner showing the newest, not five - the rest are still pending and answerable in the app. Each kind keeps its own banner, so an arriving approval never overwrites a finished turn.