@pingroom/sdk 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +153 -41
  2. package/dist/client.d.ts +14 -348
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +15 -1300
  5. package/dist/client.js.map +1 -1
  6. package/dist/errors.d.ts +32 -3
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +47 -1
  9. package/dist/errors.js.map +1 -1
  10. package/dist/http.d.ts.map +1 -1
  11. package/dist/http.js +7 -1
  12. package/dist/http.js.map +1 -1
  13. package/dist/index.d.ts +3 -5
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/guards.d.ts +7 -0
  18. package/dist/internal/guards.d.ts.map +1 -1
  19. package/dist/internal/guards.js +1 -1
  20. package/dist/internal/guards.js.map +1 -1
  21. package/dist/internal/request.d.ts +41 -0
  22. package/dist/internal/request.d.ts.map +1 -0
  23. package/dist/internal/request.js +78 -0
  24. package/dist/internal/request.js.map +1 -0
  25. package/dist/internal/retry.d.ts +9 -2
  26. package/dist/internal/retry.d.ts.map +1 -1
  27. package/dist/internal/retry.js +24 -6
  28. package/dist/internal/retry.js.map +1 -1
  29. package/dist/internal/validate.d.ts +13 -0
  30. package/dist/internal/validate.d.ts.map +1 -0
  31. package/dist/internal/validate.js +36 -0
  32. package/dist/internal/validate.js.map +1 -0
  33. package/dist/liveStatus.d.ts +106 -11
  34. package/dist/liveStatus.d.ts.map +1 -1
  35. package/dist/liveStatus.js +20 -3
  36. package/dist/liveStatus.js.map +1 -1
  37. package/dist/mcp.d.ts.map +1 -1
  38. package/dist/mcp.js +30 -8
  39. package/dist/mcp.js.map +1 -1
  40. package/dist/namespaces/actions.d.ts +50 -0
  41. package/dist/namespaces/actions.d.ts.map +1 -0
  42. package/dist/namespaces/actions.js +219 -0
  43. package/dist/namespaces/actions.js.map +1 -0
  44. package/dist/namespaces/approvals.d.ts +23 -0
  45. package/dist/namespaces/approvals.d.ts.map +1 -0
  46. package/dist/namespaces/approvals.js +78 -0
  47. package/dist/namespaces/approvals.js.map +1 -0
  48. package/dist/namespaces/attachments.d.ts +37 -0
  49. package/dist/namespaces/attachments.d.ts.map +1 -0
  50. package/dist/namespaces/attachments.js +91 -0
  51. package/dist/namespaces/attachments.js.map +1 -0
  52. package/dist/namespaces/auth.d.ts +58 -0
  53. package/dist/namespaces/auth.d.ts.map +1 -0
  54. package/dist/namespaces/auth.js +242 -0
  55. package/dist/namespaces/auth.js.map +1 -0
  56. package/dist/namespaces/directory.d.ts +10 -0
  57. package/dist/namespaces/directory.d.ts.map +1 -0
  58. package/dist/namespaces/directory.js +19 -0
  59. package/dist/namespaces/directory.js.map +1 -0
  60. package/dist/namespaces/handoffs.d.ts +47 -0
  61. package/dist/namespaces/handoffs.d.ts.map +1 -0
  62. package/dist/namespaces/handoffs.js +126 -0
  63. package/dist/namespaces/handoffs.js.map +1 -0
  64. package/dist/namespaces/inbox.d.ts +17 -0
  65. package/dist/namespaces/inbox.d.ts.map +1 -0
  66. package/dist/namespaces/inbox.js +192 -0
  67. package/dist/namespaces/inbox.js.map +1 -0
  68. package/dist/namespaces/live.d.ts +57 -0
  69. package/dist/namespaces/live.d.ts.map +1 -0
  70. package/dist/namespaces/live.js +116 -0
  71. package/dist/namespaces/live.js.map +1 -0
  72. package/dist/namespaces/notifications.d.ts +25 -0
  73. package/dist/namespaces/notifications.d.ts.map +1 -0
  74. package/dist/namespaces/notifications.js +128 -0
  75. package/dist/namespaces/notifications.js.map +1 -0
  76. package/dist/namespaces/profile.d.ts +19 -0
  77. package/dist/namespaces/profile.d.ts.map +1 -0
  78. package/dist/namespaces/profile.js +22 -0
  79. package/dist/namespaces/profile.js.map +1 -0
  80. package/dist/namespaces/questions.d.ts +41 -0
  81. package/dist/namespaces/questions.d.ts.map +1 -0
  82. package/dist/namespaces/questions.js +107 -0
  83. package/dist/namespaces/questions.js.map +1 -0
  84. package/dist/namespaces/rooms.d.ts +13 -0
  85. package/dist/namespaces/rooms.d.ts.map +1 -0
  86. package/dist/namespaces/rooms.js +41 -0
  87. package/dist/namespaces/rooms.js.map +1 -0
  88. package/dist/namespaces/webhooks.d.ts +11 -0
  89. package/dist/namespaces/webhooks.d.ts.map +1 -0
  90. package/dist/namespaces/webhooks.js +32 -0
  91. package/dist/namespaces/webhooks.js.map +1 -0
  92. package/dist/types/approvals.d.ts +62 -0
  93. package/dist/types/approvals.d.ts.map +1 -0
  94. package/dist/types/approvals.js +2 -0
  95. package/dist/types/approvals.js.map +1 -0
  96. package/dist/types/auth.d.ts +185 -0
  97. package/dist/types/auth.d.ts.map +1 -0
  98. package/dist/types/auth.js +2 -0
  99. package/dist/types/auth.js.map +1 -0
  100. package/dist/types/common.d.ts +43 -0
  101. package/dist/types/common.d.ts.map +1 -0
  102. package/dist/types/common.js +2 -0
  103. package/dist/types/common.js.map +1 -0
  104. package/dist/types/handoffs.d.ts +121 -0
  105. package/dist/types/handoffs.d.ts.map +1 -0
  106. package/dist/types/handoffs.js +2 -0
  107. package/dist/types/handoffs.js.map +1 -0
  108. package/dist/types/inbox.d.ts +51 -0
  109. package/dist/types/inbox.d.ts.map +1 -0
  110. package/dist/types/inbox.js +2 -0
  111. package/dist/types/inbox.js.map +1 -0
  112. package/dist/types/notifications.d.ts +376 -0
  113. package/dist/types/notifications.d.ts.map +1 -0
  114. package/dist/types/notifications.js +2 -0
  115. package/dist/types/notifications.js.map +1 -0
  116. package/dist/types/profile.d.ts +30 -0
  117. package/dist/types/profile.d.ts.map +1 -0
  118. package/dist/types/profile.js +3 -0
  119. package/dist/types/profile.js.map +1 -0
  120. package/dist/types/questions.d.ts +210 -0
  121. package/dist/types/questions.d.ts.map +1 -0
  122. package/dist/types/questions.js +2 -0
  123. package/dist/types/questions.js.map +1 -0
  124. package/dist/types/rooms.d.ts +163 -0
  125. package/dist/types/rooms.d.ts.map +1 -0
  126. package/dist/types/rooms.js +3 -0
  127. package/dist/types/rooms.js.map +1 -0
  128. package/dist/types.d.ts +9 -1199
  129. package/dist/types.d.ts.map +1 -1
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/webhooks.d.ts +14 -4
  133. package/dist/webhooks.d.ts.map +1 -1
  134. package/dist/webhooks.js +30 -5
  135. package/dist/webhooks.js.map +1 -1
  136. package/package.json +4 -2
  137. package/src/client.ts +115 -0
  138. package/src/errors.ts +291 -0
  139. package/src/http.ts +225 -0
  140. package/src/index.ts +97 -0
  141. package/src/internal/async.ts +54 -0
  142. package/src/internal/guards.ts +129 -0
  143. package/src/internal/request.ts +97 -0
  144. package/src/internal/retry.ts +157 -0
  145. package/src/internal/url.ts +78 -0
  146. package/src/internal/validate.ts +42 -0
  147. package/src/linkPing.ts +56 -0
  148. package/src/liveStatus.ts +337 -0
  149. package/src/locationPing.ts +101 -0
  150. package/src/mcp.ts +161 -0
  151. package/src/namespaces/actions.ts +234 -0
  152. package/src/namespaces/approvals.ts +104 -0
  153. package/src/namespaces/attachments.ts +106 -0
  154. package/src/namespaces/auth.ts +284 -0
  155. package/src/namespaces/directory.ts +21 -0
  156. package/src/namespaces/handoffs.ts +160 -0
  157. package/src/namespaces/inbox.ts +253 -0
  158. package/src/namespaces/live.ts +134 -0
  159. package/src/namespaces/notifications.ts +152 -0
  160. package/src/namespaces/profile.ts +24 -0
  161. package/src/namespaces/questions.ts +133 -0
  162. package/src/namespaces/rooms.ts +53 -0
  163. package/src/namespaces/webhooks.ts +38 -0
  164. package/src/types/approvals.ts +67 -0
  165. package/src/types/auth.ts +202 -0
  166. package/src/types/common.ts +74 -0
  167. package/src/types/handoffs.ts +148 -0
  168. package/src/types/inbox.ts +64 -0
  169. package/src/types/notifications.ts +393 -0
  170. package/src/types/profile.ts +37 -0
  171. package/src/types/questions.ts +226 -0
  172. package/src/types/rooms.ts +169 -0
  173. package/src/types.ts +17 -0
  174. package/src/version.ts +2 -0
  175. package/src/webhooks.ts +373 -0
  176. package/dist/liveActivity.d.ts +0 -101
  177. package/dist/liveActivity.d.ts.map +0 -1
  178. package/dist/liveActivity.js +0 -83
  179. package/dist/liveActivity.js.map +0 -1
package/README.md CHANGED
@@ -12,6 +12,8 @@ One typed client for everything an agent does: authenticate, create and join roo
12
12
  npm install @pingroom/sdk
13
13
  ```
14
14
 
15
+ The package is ESM-only. `import` it, or `await import('@pingroom/sdk')` from CommonJS. `require('@pingroom/sdk')` also works on Node ≥ 20.19 and ≥ 22.12, which can require an ES module; on an older Node 20.x it fails with `ERR_REQUIRE_ESM`.
16
+
15
17
  ## Quick start
16
18
 
17
19
  ```ts
@@ -53,7 +55,11 @@ the first result instead of pinging the human twice. The SDK sends it as the
53
55
  `Idempotency-Key` header, never in the body. A key is 1–255 printable ASCII
54
56
  characters, spaces included, and surrounding whitespace is trimmed as the
55
57
  server does. Anything else, such as an emoji or Cyrillic, is refused before
56
- sending with `invalid_request`, because it cannot travel as a header.
58
+ sending with `invalid_request`, because it cannot travel as a header. An empty
59
+ key (`''`) is `invalid_request` on `broadcast()`, `actions.trigger()`,
60
+ `approvals.request()` and `questions.ask()`; on `handoffs.*` and
61
+ `sendIncomingWebhook()` it is kept as it always was, meaning "no key", so pass
62
+ `undefined` rather than an empty string when you have none.
57
63
 
58
64
  ```ts
59
65
  await pr.broadcast('ab12cd', { message: 'Deploy shipped ✅', idempotencyKey: 'deploy-1.4.0-shipped' });
@@ -66,11 +72,6 @@ incoming-webhook URLs do not expose visibility, so the SDK validates the
66
72
  limit. Question and Handoff prompts, Live Status fields, and structured `data`
67
73
  use separate contracts.
68
74
 
69
- > **`pr.agents.ping()` is retired.** Handle-addressed pings across accounts
70
- > always fail with `410 cross_account_ping_retired`. An agent reaches only the
71
- > account that connected it — to reach another agent or person, share a room
72
- > (invite them, or join one they post in) and `pr.broadcast()` into it.
73
-
74
75
  ### Link pings
75
76
 
76
77
  A ping becomes a tappable link button when its `data` carries the reserved
@@ -113,7 +114,9 @@ Latitude must be within -90..90 and longitude within -180..180. `label` is
113
114
  limited to 100 Unicode characters and `address` to 255. Recipients can share
114
115
  the point or open it in an installed maps app. When reading pings,
115
116
  `extractLocationPing(ping.data)` returns the validated location or `null` for
116
- legacy or malformed data.
117
+ legacy or malformed data. (This is a point carried in a ping's `data`, not the
118
+ geofence source `trigger_source: 'location'` described under
119
+ [Rooms & quick actions](#rooms--quick-actions).)
117
120
 
118
121
  > Security note: an agent credential is a bearer token. Keep it server-side. Do not embed a long-lived token in a browser bundle or a public client.
119
122
 
@@ -183,6 +186,11 @@ principal; it does not impersonate the owner's personal PingRoom profile.
183
186
 
184
187
  `waitForPairing()` tolerates short network outages and stops when the app link
185
188
  expires. Pass an `AbortSignal` when your process needs its own cancellation.
189
+ If the client already held a credential when you called `startPairing()`, that
190
+ credential is put back when the link expires or the wait is aborted, so a
191
+ re-pairing that never completes does not leave the client on the dead pending
192
+ credential. Calling `waitForPairing()` again after an abort resumes the same
193
+ pairing.
186
194
  Pairing grants are server-authoritative. `startPairing()` sends no scope array,
187
195
  so an older installed SDK cannot accidentally mint a partial credential when
188
196
  the service adds a capability. The deprecated `scopes` option is accepted for
@@ -248,10 +256,8 @@ await pr.actions.updateMany('ab12cd', [
248
256
  { action_number: 2, label: 'Reject', icon: '❌' },
249
257
  ]);
250
258
 
251
- // One-shot modifiers on a single press. `trigger_source` is `manual` (default)
252
- // or `location`; `webhook`/`system` are stamped server-side and rejected here.
259
+ // One-shot modifiers on a single press.
253
260
  await pr.actions.trigger('ab12cd', 1, {
254
- trigger_source: 'location',
255
261
  is_urgent: true,
256
262
  requires_ack: true,
257
263
  });
@@ -262,6 +268,31 @@ only **add** an acknowledgement requirement for that one press — passing `fals
262
268
  never removes one from an action already configured to require it. Neither
263
269
  modifier edits the action's saved configuration.
264
270
 
271
+ A `requires_ack` result from `broadcast()` or `trigger()` carries
272
+ `confirmation_reach`. In a private room it names who has no device that can show
273
+ Confirm: they get a plain alert, so they may never confirm. `{ checked: false }`
274
+ means nothing was checked (public rooms, rooms over 100 recipients, a failed
275
+ lookup), not that everyone is reachable.
276
+
277
+ ```ts
278
+ const sent = await pr.broadcast(code, { message: 'Confirm', requires_ack: true });
279
+ if (sent.confirmation_reach?.checked) {
280
+ console.log(sent.confirmation_reach.unavailable_user_ids);
281
+ }
282
+ ```
283
+
284
+ `trigger_source` is `manual` (the default) or `location`; `webhook`/`system` are
285
+ stamped server-side and rejected here. **Leave it unset to press a Quick Ping.**
286
+ `location` is the device-geofence source, not a press modifier: it is for the
287
+ **room owner only** and sends the owner's Location Ping (an "Arrived at
288
+ {place}" or "Left {place}" Ping to every member, with no action number)
289
+ **instead of** the quick action in that slot, so the slot's label, sound and
290
+ acknowledgement policy are not used. A caller who is not the room owner gets
291
+ `403 location_trigger_owner_only`; a room with no enabled location trigger (or
292
+ none that listens for the reported crossing) gets `409 location_trigger_inactive`;
293
+ the operator's Location Pings switch being off gets `403 feature_disabled`. In
294
+ none of those cases does the slot's quick action fire.
295
+
265
296
  ### Quick actions that need a detail
266
297
 
267
298
  An action can require a detail from whoever presses it: `input_type` is `none`
@@ -272,7 +303,8 @@ refused with `422 quick_action_input_required` and nothing is sent.
272
303
  ```ts
273
304
  await pr.actions.update('ab12cd', 2, { label: 'Where are you?', icon: '📍', input_type: 'location' });
274
305
 
275
- const [action] = await pr.actions.list('ab12cd'); // action.input_type === 'location'
306
+ const action = (await pr.actions.list('ab12cd')).find((a) => a.action_number === 2);
307
+ if (!action) throw new Error('Slot 2 is not configured.'); // action.input_type === 'location'
276
308
  await pr.actions.trigger('ab12cd', 2, {
277
309
  quick_action_id: action.id, // refused with 409 quick_action_layout_changed if the slot moved pages
278
310
  data: { location: { latitude: 25.2048, longitude: 55.2708, label: 'Dubai Mall' } },
@@ -339,9 +371,10 @@ Updating with `regenerate_secret: true` returns a new URL and revokes the old on
339
371
  ## Attachments
340
372
 
341
373
  Send the file itself, not a link to it. Upload first, then pass the ids to any
342
- send. Bytes never travel over MCP or a JSON ping body, and the returned metadata
374
+ send. Bytes never travel inside a JSON ping body, and the returned metadata
343
375
  never carries a permanent URL — recipients read the content back through an
344
- authenticated endpoint.
376
+ authenticated endpoint. (The MCP `upload_attachment` tool takes small files as
377
+ base64, up to about 90 KiB; this method has no such limit below 5 MiB.)
345
378
 
346
379
  ```ts
347
380
  const report = await pr.attachments.upload({
@@ -419,10 +452,19 @@ for await (const ping of pr.notifications.listen({ signal: ac.signal })) {
419
452
  ```
420
453
 
421
454
  `listen()` retries transient failures (network errors, request timeouts, 429
422
- and 5xx) with a backoff that starts at `backoffMs` (default 1 s), doubles up to
423
- 30 s, and waits out a server `retryAfter`. Pass `onError` to observe those
424
- retries. Any other failure, such as a 401 after the credential is revoked or a
425
- 403, ends the iterator by throwing, with or without `onError`.
455
+ and 5xx) with a backoff based on `backoffMs` (default 1 s, minimum 250
456
+ ms: a smaller value is raised to 250 ms), doubles up to 30 s, and waits out a
457
+ server `retryAfter`. Pass `onError` to observe those retries. Any other failure,
458
+ such as a 401 after the credential is revoked or a 403, ends the iterator by
459
+ throwing, with or without `onError`. A 2xx that is not a wait answer (a captive
460
+ portal's page, say) ends it with `invalid_response`.
461
+
462
+ Retries and polls following an empty forward poll start at least 2.1 seconds
463
+ after the previous poll started, even when `backoffMs` is smaller. This paces
464
+ one loop's empty-response and retry traffic. The initial head read and nonempty
465
+ batch draining remain immediate, and all listeners (the CLI, other agents)
466
+ share the human's 30-requests-per-minute limit on that route. The default 20-second
467
+ hold is unaffected.
426
468
 
427
469
  Or a single long-poll. Without `after`, `wait()` answers at once with no pings
428
470
  and the current head cursor, so pass the cursor from a previous call:
@@ -482,10 +524,11 @@ approving `decision`, never on `decision !== 'Hold'`.
482
524
  return only a response for the id you asked about, in a known state. Anything
483
525
  else, such as an HTML page from a proxy, `{}`, or another approval, rejects with
484
526
  `invalid_response` instead of being returned as an outcome. The wait routes
485
- share a 30-requests-per-minute limit per human, so these loops retry a 429
527
+ each have their own 30-requests-per-minute limit per human, shared by every waiter on that route, so these loops retry a 429
486
528
  after its `retryAfter`, and retry network errors and 5xx with a backoff capped
487
- at 30 s. Approval and question polls start at least 1 s apart, and handoff
488
- polls 2.1 s apart, even if something in between answers `pending` at once.
529
+ at 30 s. Approval, question and handoff polls start at least 2.1 s apart (one
530
+ loop stays under the limit), even if something in between answers `pending` at
531
+ once.
489
532
 
490
533
  The retries are bounded without a signal. Once only failures have come back
491
534
  since the last valid response, a wait gives up 5 minutes after the last
@@ -533,8 +576,8 @@ const answered = await pr.questions.waitForAnswer(q.id);
533
576
 
534
577
  switch (answered.state) {
535
578
  case 'answered':
536
- console.log(`${answered.answer.responder?.display_name} chose ${answered.answer.value}`);
537
- // → answered.answer.value is 'prod' | 'staging' | 'cancel'
579
+ console.log(`${answered.answer?.responder?.display_name} chose ${answered.answer?.value}`);
580
+ // → answered.answer?.value is 'prod' | 'staging' | 'cancel'
538
581
  break;
539
582
  case 'expired': /* nobody answered in time */ break;
540
583
  case 'cancelled': /* the asker withdrew it */ break;
@@ -632,7 +675,9 @@ callback cannot rewrite the required receipt-before-answer sequence. It throws
632
675
  `answered_without_completion`. Expiry and cancellation throw the same domain
633
676
  error with their corresponding reasons. Caller aborts reject with the signal's
634
677
  reason. None of these paths revoke or change the client's active credential.
635
- Network/API errors remain `PingRoomError`. Use
678
+ A transient wait failure (a dropped connection, a request timeout, a 429 or a
679
+ 5xx) is retried with backoff until `overallTimeoutMs`; any other API error
680
+ remains a `PingRoomError`. Use
636
681
  `pr.inbox.ensure()` directly when you only want to create or inspect the
637
682
  onboarding Question without waiting. Call `activate()` again after an expired,
638
683
  cancelled, or `answered_without_completion` result; the server replays a viable
@@ -687,7 +732,10 @@ A room's incoming-webhook URL carries its own secret — ideal for CI/deploy hoo
687
732
  ```ts
688
733
  import { sendIncomingWebhook } from '@pingroom/sdk';
689
734
 
690
- await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL, {
735
+ const webhookUrl = process.env.PINGROOM_WEBHOOK_URL;
736
+ if (!webhookUrl) throw new Error('Set PINGROOM_WEBHOOK_URL to the room webhook URL.');
737
+
738
+ await sendIncomingWebhook(webhookUrl, {
691
739
  message: 'Build #42 passed',
692
740
  data: { commit: 'abc123' },
693
741
  requires_ack: true,
@@ -700,7 +748,10 @@ await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL, {
700
748
  See [Live Activities](#live-activities) below for the full picture. From a webhook, put `live_status` at the **top level** of the payload:
701
749
 
702
750
  ```ts
703
- await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL, {
751
+ const webhookUrl = process.env.PINGROOM_WEBHOOK_URL;
752
+ if (!webhookUrl) throw new Error('Set PINGROOM_WEBHOOK_URL to the room webhook URL.');
753
+
754
+ await sendIncomingWebhook(webhookUrl, {
704
755
  message: 'Deploying v1.4.0',
705
756
  correlation_id: 'deploy-42',
706
757
  live_status: { state: 'running', template: 'progress', progress: 0.4 },
@@ -713,7 +764,7 @@ await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL, {
713
764
 
714
765
  A **live-status stream** is one thing being tracked, keyed by your `correlation_id`. The first ping starts a self-updating card on every room member's Lock Screen (iOS Live Activity / Dynamic Island, Android ongoing live update, and a full inline card in the app). Later `running` pings move it **silently** — no new notification. The first `done`/`failed` sends one completion alert and ends it.
715
766
 
716
- Protocol: <https://pingroom.io/liveactivities.md> (v1.2).
767
+ Protocol: <https://pingroom.io/liveactivities.md> (v1.4).
717
768
 
718
769
  ### Two ways to send one
719
770
 
@@ -730,8 +781,10 @@ await pr.live.push('AB12CD', liveStatus.progress('deploy-42', {
730
781
 
731
782
  // B. A room's incoming webhook (Pro) — no token. The builder returns
732
783
  // { correlation_id, live_status }, which is exactly the webhook body.
784
+ const webhookUrl = process.env.PINGROOM_WEBHOOK_URL;
785
+ if (!webhookUrl) throw new Error('Set PINGROOM_WEBHOOK_URL to the room webhook URL.');
733
786
  await sendIncomingWebhook(
734
- process.env.PINGROOM_WEBHOOK_URL,
787
+ webhookUrl,
735
788
  liveStatus.progress('deploy-42', {
736
789
  state: 'running',
737
790
  message: 'Building image',
@@ -749,12 +802,13 @@ There is a third producer — a person driving a stream from the app composer ov
749
802
  | Builder | Renders |
750
803
  |---|---|
751
804
  | `liveStatus.status` | Title, message, optional progress |
752
- | `liveStatus.steps` | Segmented tracker; `steps` (2–8 labels) required on the first ping and immutable after |
805
+ | `liveStatus.steps` | Segmented tracker; `steps` (2–8 labels) required on the first ping and immutable after: later pings resend the identical list or omit it, and a different list on a `running` ping throws `PingRoomError` `steps_immutable` (422); a `done`/`failed` ping ignores it and still ends the stream |
753
806
  | `liveStatus.progress` | Determinate bar with optional `eta_at` |
754
807
  | `liveStatus.metrics` | Up to 3 `{label, value}` counters |
755
- | `liveStatus.countdown` | Large live timer to `deadline_at` |
808
+ | `liveStatus.countdown` | Large live timer to `deadline_at`. A later ping may move the deadline (send the absolute epoch); `duration_seconds` is read on the first ping only and moves by the same amount as the deadline |
756
809
  | `liveStatus.question` | `prompt` + up to 4 `{value, label}` options on the lock screen |
757
810
  | `liveStatus.matchup` | `left` / `center` / `right` — a score or A-vs-B |
811
+ | `liveStatus.claim` | "Who's on it?": a request one room member takes on. See [Who's on it?](#whos-on-it) |
758
812
 
759
813
  ```ts
760
814
  await pr.live.push('AB12CD', liveStatus.steps('release-7', {
@@ -772,6 +826,58 @@ await pr.live.push('AB12CD', liveStatus.matchup('game-3', {
772
826
  }));
773
827
  ```
774
828
 
829
+ ### Who's on it?
830
+
831
+ A **claim request** asks the room "who's on it?" and lets one member take it. It is template `claim`, and it works in **private standard rooms only** (`422 claim_private_rooms_only` in a public or personal room). Pass the request text as `title` (1 to 120 characters, or `live_status.message` without a title). An optional `deadline_at` must be in the future and at most 8 hours away. The first ping must be `running`. The server writes the claim itself and ignores any `claim` block you send.
832
+
833
+ ```ts
834
+ // The asking credential acts for the room owner.
835
+ // The claiming credential must act for a different human member of that room.
836
+ const asker = new PingRoom({ token: process.env.ASKER_AGENT_TOKEN });
837
+ const member = new PingRoom({ token: process.env.MEMBER_AGENT_TOKEN });
838
+ const { notification_id } = await asker.live.push('AB12CD', liveStatus.claim(
839
+ 'milk-1',
840
+ { state: 'running', deadline_at: Math.floor(Date.now() / 1000) + 3600 },
841
+ { title: 'Buy milk' },
842
+ ));
843
+
844
+ // The claiming credential acts for another member, who is not the asker.
845
+ const taken = await member.live.claim('AB12CD', notification_id, 'claim');
846
+ console.log(taken.claim.status, taken.claim.by?.name, taken.idempotent);
847
+ ```
848
+
849
+ `pr.live.claim(inviteCode, notificationId, action)` takes `'claim' | 'release' | 'done' | 'cancel'` and resolves `{ notification_id, correlation_id, claim, state, idempotent }`. It needs the `pingroom:live:write` scope.
850
+
851
+ | Action | Who | From | To |
852
+ |---|---|---|---|
853
+ | `claim` | any member but the asker | open | claimed |
854
+ | `release` | the claimer | claimed | open, or expired past the deadline |
855
+ | `done` | the claimer, or the asker at any time | open (asker only) or claimed | done |
856
+ | `cancel` | the asker, or a room owner or admin | open or claimed | cancelled |
857
+
858
+ `release` returns the request to `open` only while it can still be taken. Past its deadline (or 4 hours after it was made, when it has none) it ends the request as `expired` instead, and the asker hears "No one took it". That is a 200, not an error: `claim.status` is `'expired'`, `state` is `'failed'`, and `claim.by` and `claim.at` are `null`. A retry resolves the same way with `idempotent: true`.
859
+
860
+ The asker is the stream's sender, or the room owner for an agent or webhook stream. Two claims at the same moment have one winner. The other throws `PingRoomError` `claim_conflict` (409), whose `body.claim` names who holds it. Repeating your own last action resolves with `idempotent: true` and sends nothing. A malformed or mismatched success receipt throws `invalid_response`; the SDK does not automatically repeat the POST.
861
+
862
+ ```ts
863
+ import { PingRoomError } from '@pingroom/sdk';
864
+
865
+ try {
866
+ await member.live.claim('AB12CD', notification_id, 'claim');
867
+ } catch (error) {
868
+ if (error instanceof PingRoomError && error.code === 'claim_conflict') {
869
+ const { claim } = error.body as { claim: { by?: { name: string } } };
870
+ console.log(`${claim.by?.name} got it first`);
871
+ } else throw error;
872
+ }
873
+ ```
874
+
875
+ Refusals: `claim_conflict` (409), `claim_own_request` (422), `claim_private_rooms_only` (422), `claim_not_found` (404) and `forbidden` (403). Creation can also fail with `claim_request_invalid`, `claim_must_start_running` and `claim_deadline_out_of_range` (all 422). `LIVE_CLAIM_ERROR_CODES` lists the claim codes.
876
+
877
+ A webhook has no member identity, so it cannot claim or release. It ends its own request with `liveStatus.done()` (done) or `liveStatus.failed()` (cancelled). An ordinary `running` leg on an existing request changes nothing.
878
+
879
+ A request ends by itself. An open one expires at its deadline, or after 4 hours without one. A claimed one stays live past its deadline, marked `late`, and ends as `unfinished` at 7 h 55 m. `pr.live.get()` returns `title` and `claim` for a claim request. Claim support is newer than SDK 0.8.0.
880
+
775
881
  ### Ending and reading back
776
882
 
777
883
  ```ts
@@ -789,16 +895,12 @@ const snap = await pr.live.get('AB12CD', 'deploy-42');
789
895
  if (snap) console.log(snap.template, snap.current_step, snap.updated_at);
790
896
  ```
791
897
 
792
- `get()` returns **every** stored field (`state`, `progress`, `message`, `category`, `template`, `agent_id`, `accent_override`, `eta_at`, `deadline_at`, `duration_seconds`, `metrics`, `prompt`, `options`, `left`, `right`, `center`, `steps`, `current_step`) plus `action_state` and `updated_at`. Fields you never set come back as `null`, not omitted.
898
+ `get()` returns **every** stored field (`state`, `progress`, `message`, `category`, `template`, `agent_id`, `accent_override`, `eta_at`, `deadline_at`, `duration_seconds`, `metrics`, `prompt`, `options`, `left`, `right`, `center`, `steps`, `current_step`) plus `action_state` and `updated_at`. Fields you never set come back as `null`, not omitted. A claim request adds `title` and `claim`.
793
899
 
794
900
  ### Ownership and limits
795
901
 
796
902
  A stream belongs to the credential that started it. An agent can never advance a webhook's stream or another registration's, even under the same `correlation_id`. Updates are throttled to ~6/min per correlation and 10 new streams/min per producer; free agent accounts get 5 new streams/day (`402 free_limit_reached`). Creation is the only charged leg.
797
903
 
798
- ### `liveActivity.*` is not this
799
-
800
- The exported `liveActivity.*` builders produce the frozen **native** `{ attributes, content_state }` push-layer shape for tests and tooling. No endpoint accepts them as input, and a `live_activity` block inside a `broadcast()` `data` object is carried as opaque structured data — it starts nothing. Use `liveStatus.*` to drive a real activity.
801
-
802
904
  ## Verifying outgoing webhooks
803
905
 
804
906
  When PingRoom POSTs an event to *your* server, verify it before trusting it. New deliveries carry two lower-case hex HMAC-SHA256 headers:
@@ -822,10 +924,12 @@ import {
822
924
  const signingSecret = process.env.PINGROOM_SIGNING_SECRET;
823
925
  if (!signingSecret) throw new Error('Set PINGROOM_SIGNING_SECRET to the webhook signing_secret.');
824
926
 
825
- // Express example — capture the raw body (e.g. express.raw())
826
- app.post('/pingroom', async (req, res) => {
927
+ // Express example. PingRoom sends `Content-Type: application/json`, which
928
+ // express.raw() ignores unless you name the type, so pass it on this route and
929
+ // keep it ahead of any express.json().
930
+ app.post('/pingroom', express.raw({ type: 'application/json' }), async (req, res) => {
827
931
  const ok = await verifyWebhookSignature({
828
- payload: req.body, // a Buffer/string of the RAW body
932
+ payload: req.body, // a Buffer of the RAW body
829
933
  signature: req.get(WEBHOOK_SIGNATURE_HEADER),
830
934
  signatureV2: req.get(WEBHOOK_SIGNATURE_V2_HEADER),
831
935
  timestamp: req.get(WEBHOOK_TIMESTAMP_HEADER),
@@ -839,6 +943,13 @@ app.post('/pingroom', async (req, res) => {
839
943
  });
840
944
  ```
841
945
 
946
+ `verifyWebhookSignature` returns `false` for a signature that does not match, and
947
+ **throws a `TypeError`** when `payload` is not a string, `ArrayBuffer` or
948
+ `Buffer`/typed array. That is what a body parser that did not capture the raw
949
+ bytes leaves behind (`{}` on Express 4, `undefined` on Express 5, a parsed
950
+ object after `express.json()`), and it is a bug in the receiver, not a forged
951
+ delivery, so it is not reported as `false`.
952
+
842
953
  ## Redeem a gift or promotional code
843
954
 
844
955
  ```ts
@@ -905,7 +1016,7 @@ try {
905
1016
  }
906
1017
  ```
907
1018
 
908
- The SDK also raises a few client-side codes: `network_error` and `timeout` (status 0), `redirect_refused` when the API answers with a redirect (the SDK never follows one, so a token, upload or webhook body cannot be forwarded to another origin), `invalid_response` when a wait loop receives something that is not the requested approval, Question or handoff, `unavailable` when a wait loop stops retrying an unreachable API (see [approvals](#human-in-the-loop-approvals); the last failure is `error.cause`), and `invalid_request` for input refused before sending, including an `idempotencyKey` that is not 1–255 printable ASCII characters.
1019
+ The SDK also raises a few client-side codes: `network_error` and `timeout` (status 0), `redirect_refused` when the API answers with a redirect (the SDK never follows one, so a token, upload or webhook body cannot be forwarded to another origin), `invalid_response` when a wait loop receives something that is not the requested approval, Question or handoff, when any call gets a 2xx whose body is not JSON (a proxy's page, say), or when `sendIncomingWebhook()` gets a 2xx that is not a JSON object with `success: true` (a misrouted webhook URL), `unavailable` when a wait loop stops retrying an unreachable API (see [approvals](#human-in-the-loop-approvals); the last failure is `error.cause`), and `invalid_request` for input refused before sending, including an `idempotencyKey` that is not 1–255 printable ASCII characters.
909
1020
 
910
1021
  The coded vocabulary is exported both as types and as runtime arrays, one per
911
1022
  surface, so you can branch exhaustively:
@@ -915,9 +1026,10 @@ surface, so you can branch exhaustively:
915
1026
  | `HandoffErrorCode` / `HANDOFF_ERROR_CODES` | `handoffs.*` |
916
1027
  | `RoomScopedErrorCode` / `ROOM_SCOPED_ERROR_CODES` | every call whose path names a room (`rooms.get`, `actions.*`, `broadcast`, `webhooks.*`, `live.*`) |
917
1028
  | `AgentInboxErrorCode` / `AGENT_INBOX_ERROR_CODES` | `inbox.ensure()` / `inbox.activate()` |
918
- | `QuickActionErrorCode` / `QUICK_ACTION_ERROR_CODES` | `actions.*` and a `broadcast()` naming an `action_number`: `quick_action_input_required`, `quick_action_input_type`, `action_not_configured`, `pro_required`, `quick_action_layout_changed`, `quick_action_page_in_use` |
1029
+ | `QuickActionErrorCode` / `QUICK_ACTION_ERROR_CODES` | `actions.*` and a `broadcast()` naming an `action_number`: `quick_action_input_required`, `quick_action_input_type`, `action_not_configured`, `pro_required`, `quick_action_layout_changed`, `quick_action_page_in_use`; and, on `actions.trigger()` with `trigger_source: 'location'` only, `location_trigger_owner_only` (403) and `location_trigger_inactive` (409) |
1030
+ | `CommonErrorCode` / `COMMON_ERROR_CODES` | any call: `rate_limited` (429, a route throttle; carries `retryAfter`) and `feature_disabled` (403, the operator switched the capability off; `error.body.feature` names the switch, for example `sending.attachments`, and retrying cannot succeed) |
919
1031
  | `RequestLimitErrorCode` / `REQUEST_LIMIT_ERROR_CODES` | `payload_too_large` (HTTP 413 from the server on any oversized body) and `attachment_too_large` (raised locally by `attachments.upload()` above 5 MiB, before any bytes travel) |
920
- | `AgentErrorCode` | all five, for one shared branch |
1032
+ | `AgentErrorCode` | all six, for one shared branch |
921
1033
 
922
1034
  ## Configuration
923
1035
 
@@ -925,7 +1037,7 @@ surface, so you can branch exhaustively:
925
1037
  new PingRoom({
926
1038
  token: '...', // agent credential (optional for public reads)
927
1039
  baseUrl: 'https://api.pingroom.io', // or env PINGROOM_API_URL / PINGROOM_BASE_URL
928
- timeoutMs: 30000, // long-poll calls extend this automatically
1040
+ timeoutMs: 30000, // per-request deadline; a long-poll call (wait, listen, waitFor*) uses its own hold time + 10 s instead
929
1041
  fetch: customFetch, // inject a fetch (tests / non-global-fetch runtimes)
930
1042
  allowInsecure: false, // allow plain-http base URLs (off by default)
931
1043
  });