@pingroom/sdk 0.7.1 → 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 (191) hide show
  1. package/README.md +288 -44
  2. package/dist/client.d.ts +27 -312
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +33 -1154
  5. package/dist/client.js.map +1 -1
  6. package/dist/errors.d.ts +40 -6
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +56 -4
  9. package/dist/errors.js.map +1 -1
  10. package/dist/http.d.ts +12 -1
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +47 -18
  13. package/dist/http.js.map +1 -1
  14. package/dist/index.d.ts +3 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal/async.d.ts +2 -0
  19. package/dist/internal/async.d.ts.map +1 -1
  20. package/dist/internal/async.js +6 -0
  21. package/dist/internal/async.js.map +1 -1
  22. package/dist/internal/guards.d.ts +16 -0
  23. package/dist/internal/guards.d.ts.map +1 -1
  24. package/dist/internal/guards.js +22 -1
  25. package/dist/internal/guards.js.map +1 -1
  26. package/dist/internal/request.d.ts +41 -0
  27. package/dist/internal/request.d.ts.map +1 -0
  28. package/dist/internal/request.js +78 -0
  29. package/dist/internal/request.js.map +1 -0
  30. package/dist/internal/retry.d.ts +65 -0
  31. package/dist/internal/retry.d.ts.map +1 -0
  32. package/dist/internal/retry.js +126 -0
  33. package/dist/internal/retry.js.map +1 -0
  34. package/dist/internal/url.d.ts +9 -0
  35. package/dist/internal/url.d.ts.map +1 -1
  36. package/dist/internal/url.js +25 -0
  37. package/dist/internal/url.js.map +1 -1
  38. package/dist/internal/validate.d.ts +13 -0
  39. package/dist/internal/validate.d.ts.map +1 -0
  40. package/dist/internal/validate.js +36 -0
  41. package/dist/internal/validate.js.map +1 -0
  42. package/dist/linkPing.d.ts.map +1 -1
  43. package/dist/linkPing.js +6 -2
  44. package/dist/linkPing.js.map +1 -1
  45. package/dist/liveStatus.d.ts +106 -11
  46. package/dist/liveStatus.d.ts.map +1 -1
  47. package/dist/liveStatus.js +20 -3
  48. package/dist/liveStatus.js.map +1 -1
  49. package/dist/mcp.d.ts.map +1 -1
  50. package/dist/mcp.js +40 -10
  51. package/dist/mcp.js.map +1 -1
  52. package/dist/namespaces/actions.d.ts +50 -0
  53. package/dist/namespaces/actions.d.ts.map +1 -0
  54. package/dist/namespaces/actions.js +219 -0
  55. package/dist/namespaces/actions.js.map +1 -0
  56. package/dist/namespaces/approvals.d.ts +23 -0
  57. package/dist/namespaces/approvals.d.ts.map +1 -0
  58. package/dist/namespaces/approvals.js +78 -0
  59. package/dist/namespaces/approvals.js.map +1 -0
  60. package/dist/namespaces/attachments.d.ts +37 -0
  61. package/dist/namespaces/attachments.d.ts.map +1 -0
  62. package/dist/namespaces/attachments.js +91 -0
  63. package/dist/namespaces/attachments.js.map +1 -0
  64. package/dist/namespaces/auth.d.ts +58 -0
  65. package/dist/namespaces/auth.d.ts.map +1 -0
  66. package/dist/namespaces/auth.js +242 -0
  67. package/dist/namespaces/auth.js.map +1 -0
  68. package/dist/namespaces/directory.d.ts +10 -0
  69. package/dist/namespaces/directory.d.ts.map +1 -0
  70. package/dist/namespaces/directory.js +19 -0
  71. package/dist/namespaces/directory.js.map +1 -0
  72. package/dist/namespaces/handoffs.d.ts +47 -0
  73. package/dist/namespaces/handoffs.d.ts.map +1 -0
  74. package/dist/namespaces/handoffs.js +126 -0
  75. package/dist/namespaces/handoffs.js.map +1 -0
  76. package/dist/namespaces/inbox.d.ts +17 -0
  77. package/dist/namespaces/inbox.d.ts.map +1 -0
  78. package/dist/namespaces/inbox.js +192 -0
  79. package/dist/namespaces/inbox.js.map +1 -0
  80. package/dist/namespaces/live.d.ts +57 -0
  81. package/dist/namespaces/live.d.ts.map +1 -0
  82. package/dist/namespaces/live.js +116 -0
  83. package/dist/namespaces/live.js.map +1 -0
  84. package/dist/namespaces/notifications.d.ts +25 -0
  85. package/dist/namespaces/notifications.d.ts.map +1 -0
  86. package/dist/namespaces/notifications.js +128 -0
  87. package/dist/namespaces/notifications.js.map +1 -0
  88. package/dist/namespaces/profile.d.ts +19 -0
  89. package/dist/namespaces/profile.d.ts.map +1 -0
  90. package/dist/namespaces/profile.js +22 -0
  91. package/dist/namespaces/profile.js.map +1 -0
  92. package/dist/namespaces/questions.d.ts +41 -0
  93. package/dist/namespaces/questions.d.ts.map +1 -0
  94. package/dist/namespaces/questions.js +107 -0
  95. package/dist/namespaces/questions.js.map +1 -0
  96. package/dist/namespaces/rooms.d.ts +13 -0
  97. package/dist/namespaces/rooms.d.ts.map +1 -0
  98. package/dist/namespaces/rooms.js +41 -0
  99. package/dist/namespaces/rooms.js.map +1 -0
  100. package/dist/namespaces/webhooks.d.ts +11 -0
  101. package/dist/namespaces/webhooks.d.ts.map +1 -0
  102. package/dist/namespaces/webhooks.js +32 -0
  103. package/dist/namespaces/webhooks.js.map +1 -0
  104. package/dist/types/approvals.d.ts +62 -0
  105. package/dist/types/approvals.d.ts.map +1 -0
  106. package/dist/types/approvals.js +2 -0
  107. package/dist/types/approvals.js.map +1 -0
  108. package/dist/types/auth.d.ts +185 -0
  109. package/dist/types/auth.d.ts.map +1 -0
  110. package/dist/types/auth.js +2 -0
  111. package/dist/types/auth.js.map +1 -0
  112. package/dist/types/common.d.ts +43 -0
  113. package/dist/types/common.d.ts.map +1 -0
  114. package/dist/types/common.js +2 -0
  115. package/dist/types/common.js.map +1 -0
  116. package/dist/types/handoffs.d.ts +121 -0
  117. package/dist/types/handoffs.d.ts.map +1 -0
  118. package/dist/types/handoffs.js +2 -0
  119. package/dist/types/handoffs.js.map +1 -0
  120. package/dist/types/inbox.d.ts +51 -0
  121. package/dist/types/inbox.d.ts.map +1 -0
  122. package/dist/types/inbox.js +2 -0
  123. package/dist/types/inbox.js.map +1 -0
  124. package/dist/types/notifications.d.ts +376 -0
  125. package/dist/types/notifications.d.ts.map +1 -0
  126. package/dist/types/notifications.js +2 -0
  127. package/dist/types/notifications.js.map +1 -0
  128. package/dist/types/profile.d.ts +30 -0
  129. package/dist/types/profile.d.ts.map +1 -0
  130. package/dist/types/profile.js +3 -0
  131. package/dist/types/profile.js.map +1 -0
  132. package/dist/types/questions.d.ts +210 -0
  133. package/dist/types/questions.d.ts.map +1 -0
  134. package/dist/types/questions.js +2 -0
  135. package/dist/types/questions.js.map +1 -0
  136. package/dist/types/rooms.d.ts +163 -0
  137. package/dist/types/rooms.d.ts.map +1 -0
  138. package/dist/types/rooms.js +3 -0
  139. package/dist/types/rooms.js.map +1 -0
  140. package/dist/types.d.ts +9 -1035
  141. package/dist/types.d.ts.map +1 -1
  142. package/dist/version.d.ts +1 -1
  143. package/dist/version.js +1 -1
  144. package/dist/webhooks.d.ts +32 -5
  145. package/dist/webhooks.d.ts.map +1 -1
  146. package/dist/webhooks.js +44 -14
  147. package/dist/webhooks.js.map +1 -1
  148. package/package.json +4 -2
  149. package/src/client.ts +115 -0
  150. package/src/errors.ts +291 -0
  151. package/src/http.ts +225 -0
  152. package/src/index.ts +97 -0
  153. package/src/internal/async.ts +54 -0
  154. package/src/internal/guards.ts +129 -0
  155. package/src/internal/request.ts +97 -0
  156. package/src/internal/retry.ts +157 -0
  157. package/src/internal/url.ts +78 -0
  158. package/src/internal/validate.ts +42 -0
  159. package/src/linkPing.ts +56 -0
  160. package/src/liveStatus.ts +337 -0
  161. package/src/locationPing.ts +101 -0
  162. package/src/mcp.ts +161 -0
  163. package/src/namespaces/actions.ts +234 -0
  164. package/src/namespaces/approvals.ts +104 -0
  165. package/src/namespaces/attachments.ts +106 -0
  166. package/src/namespaces/auth.ts +284 -0
  167. package/src/namespaces/directory.ts +21 -0
  168. package/src/namespaces/handoffs.ts +160 -0
  169. package/src/namespaces/inbox.ts +253 -0
  170. package/src/namespaces/live.ts +134 -0
  171. package/src/namespaces/notifications.ts +152 -0
  172. package/src/namespaces/profile.ts +24 -0
  173. package/src/namespaces/questions.ts +133 -0
  174. package/src/namespaces/rooms.ts +53 -0
  175. package/src/namespaces/webhooks.ts +38 -0
  176. package/src/types/approvals.ts +67 -0
  177. package/src/types/auth.ts +202 -0
  178. package/src/types/common.ts +74 -0
  179. package/src/types/handoffs.ts +148 -0
  180. package/src/types/inbox.ts +64 -0
  181. package/src/types/notifications.ts +393 -0
  182. package/src/types/profile.ts +37 -0
  183. package/src/types/questions.ts +226 -0
  184. package/src/types/rooms.ts +169 -0
  185. package/src/types.ts +17 -0
  186. package/src/version.ts +2 -0
  187. package/src/webhooks.ts +373 -0
  188. package/dist/liveActivity.d.ts +0 -101
  189. package/dist/liveActivity.d.ts.map +0 -1
  190. package/dist/liveActivity.js +0 -83
  191. 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
@@ -37,8 +39,30 @@ await pr.broadcast('ab12cd', {
37
39
  is_urgent: true,
38
40
  });
39
41
 
40
- // Listen for what comes back
41
- const { notifications, cursor } = await pr.notifications.wait({ timeout: 20 });
42
+ // Listen for what comes back. listen() reads the current head first, then
43
+ // long-polls forward; stop it with an AbortSignal.
44
+ const ac = new AbortController();
45
+ for await (const ping of pr.notifications.listen({ signal: ac.signal })) {
46
+ console.log(ping.message);
47
+ ac.abort();
48
+ }
49
+ ```
50
+
51
+ A send that times out may still have been delivered. Pass an `idempotencyKey`
52
+ to `broadcast()`, `actions.trigger()`, `approvals.request()` or
53
+ `questions.ask()` and reuse the same key when you retry: the server replays
54
+ the first result instead of pinging the human twice. The SDK sends it as the
55
+ `Idempotency-Key` header, never in the body. A key is 1–255 printable ASCII
56
+ characters, spaces included, and surrounding whitespace is trimmed as the
57
+ server does. Anything else, such as an emoji or Cyrillic, is refused before
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.
63
+
64
+ ```ts
65
+ await pr.broadcast('ab12cd', { message: 'Deploy shipped ✅', idempotencyKey: 'deploy-1.4.0-shipped' });
42
66
  ```
43
67
 
44
68
  Ping titles are limited to 40 characters. Visible bodies are limited to 120
@@ -48,11 +72,6 @@ incoming-webhook URLs do not expose visibility, so the SDK validates the
48
72
  limit. Question and Handoff prompts, Live Status fields, and structured `data`
49
73
  use separate contracts.
50
74
 
51
- > **`pr.agents.ping()` is retired.** Handle-addressed pings across accounts
52
- > always fail with `410 cross_account_ping_retired`. An agent reaches only the
53
- > account that connected it — to reach another agent or person, share a room
54
- > (invite them, or join one they post in) and `pr.broadcast()` into it.
55
-
56
75
  ### Link pings
57
76
 
58
77
  A ping becomes a tappable link button when its `data` carries the reserved
@@ -95,7 +114,9 @@ Latitude must be within -90..90 and longitude within -180..180. `label` is
95
114
  limited to 100 Unicode characters and `address` to 255. Recipients can share
96
115
  the point or open it in an installed maps app. When reading pings,
97
116
  `extractLocationPing(ping.data)` returns the validated location or `null` for
98
- 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).)
99
120
 
100
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.
101
122
 
@@ -165,6 +186,11 @@ principal; it does not impersonate the owner's personal PingRoom profile.
165
186
 
166
187
  `waitForPairing()` tolerates short network outages and stops when the app link
167
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.
168
194
  Pairing grants are server-authoritative. `startPairing()` sends no scope array,
169
195
  so an older installed SDK cannot accidentally mint a partial credential when
170
196
  the service adds a capability. The deprecated `scopes` option is accepted for
@@ -197,6 +223,18 @@ pr.setToken(fresh.credential);
197
223
  await pr.auth.revoke();
198
224
  ```
199
225
 
226
+ Check what a credential is connected to before sending, and disconnect it when
227
+ you are done:
228
+
229
+ ```ts
230
+ const info = await pr.auth.connection();
231
+ console.log(info.owner.id, info.owner.name, info.handle, info.home_room?.invite_code);
232
+
233
+ // Revokes this credential and its refresh credentials.
234
+ await pr.auth.disconnect();
235
+ pr.setToken(null);
236
+ ```
237
+
200
238
  Generic MCP clients (Cursor, Claude Desktop, Claude Code) should use the standard OAuth 2.1 lane at `/.well-known/oauth-authorization-server` instead — see [pingroom.io/connect-mcp.md](https://pingroom.io/connect-mcp.md).
201
239
 
202
240
  ## Rooms & quick actions
@@ -218,10 +256,8 @@ await pr.actions.updateMany('ab12cd', [
218
256
  { action_number: 2, label: 'Reject', icon: '❌' },
219
257
  ]);
220
258
 
221
- // One-shot modifiers on a single press. `trigger_source` is `manual` (default)
222
- // or `location`; `webhook`/`system` are stamped server-side and rejected here.
259
+ // One-shot modifiers on a single press.
223
260
  await pr.actions.trigger('ab12cd', 1, {
224
- trigger_source: 'location',
225
261
  is_urgent: true,
226
262
  requires_ack: true,
227
263
  });
@@ -232,6 +268,31 @@ only **add** an acknowledgement requirement for that one press — passing `fals
232
268
  never removes one from an action already configured to require it. Neither
233
269
  modifier edits the action's saved configuration.
234
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
+
235
296
  ### Quick actions that need a detail
236
297
 
237
298
  An action can require a detail from whoever presses it: `input_type` is `none`
@@ -242,7 +303,8 @@ refused with `422 quick_action_input_required` and nothing is sent.
242
303
  ```ts
243
304
  await pr.actions.update('ab12cd', 2, { label: 'Where are you?', icon: '📍', input_type: 'location' });
244
305
 
245
- 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'
246
308
  await pr.actions.trigger('ab12cd', 2, {
247
309
  quick_action_id: action.id, // refused with 409 quick_action_layout_changed if the slot moved pages
248
310
  data: { location: { latitude: 25.2048, longitude: 55.2708, label: 'Dubai Mall' } },
@@ -309,9 +371,10 @@ Updating with `regenerate_secret: true` returns a new URL and revokes the old on
309
371
  ## Attachments
310
372
 
311
373
  Send the file itself, not a link to it. Upload first, then pass the ids to any
312
- 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
313
375
  never carries a permanent URL — recipients read the content back through an
314
- 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.)
315
378
 
316
379
  ```ts
317
380
  const report = await pr.attachments.upload({
@@ -353,10 +416,26 @@ const ac = new AbortController();
353
416
 
354
417
  for await (const ping of pr.notifications.listen({ signal: ac.signal })) {
355
418
  console.log(ping.sender?.name, ping.message, ping.data);
356
- if (ping.message === 'stop') ac.abort();
419
+ if (ping.message === 'stop') ac.abort(); // sent by another member of the room
357
420
  }
358
421
  ```
359
422
 
423
+ `listen()` delivers what the other members of your rooms send. It leaves out
424
+ everything sent as the connected account: your own sends, your human's own
425
+ pings and replies, other agents on the same account, and incoming-webhook and
426
+ Scheduled Pings in rooms the account owns (those are sent as the owner). So the
427
+ `stop` above has to come from someone else. To let your human stop the loop,
428
+ ask them, and abort when they approve:
429
+
430
+ ```ts
431
+ const stop = await pr.questions.ask('ab12cd', { prompt: 'Stop listening?', ttl: 86400 });
432
+
433
+ pr.questions
434
+ .waitForAnswer(stop.id, { signal: ac.signal })
435
+ .then((q) => { if (q.state === 'answered' && q.answer?.value === 'approve') ac.abort(); })
436
+ .catch(() => {}); // rejects with the abort reason once the loop has stopped
437
+ ```
438
+
360
439
  A listened ping carries two fields worth branching on. `ping.is_handoff` is true
361
440
  when the ping is a handoff rather than an ordinary ping, and `ping.question` is
362
441
  the embedded `Question` (the same wire shape `pr.questions` returns) when the
@@ -372,7 +451,23 @@ for await (const ping of pr.notifications.listen({ signal: ac.signal })) {
372
451
  }
373
452
  ```
374
453
 
375
- Or a single long-poll:
454
+ `listen()` retries transient failures (network errors, request timeouts, 429
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.
468
+
469
+ Or a single long-poll. Without `after`, `wait()` answers at once with no pings
470
+ and the current head cursor, so pass the cursor from a previous call:
376
471
 
377
472
  ```ts
378
473
  const { notifications, cursor } = await pr.notifications.wait({ after: lastCursor, timeout: 20 });
@@ -413,6 +508,7 @@ const approval = await pr.approvals.request('ab12cd', {
413
508
  question: 'Ship release 1.4.0 to production?',
414
509
  options: ['Ship it', 'Hold'],
415
510
  correlation_id: 'deploy-1.4.0',
511
+ idempotencyKey: 'deploy-1.4.0-approval', // reuse when retrying this request
416
512
  });
417
513
 
418
514
  const decided = await pr.approvals.waitForDecision(approval.id);
@@ -421,13 +517,44 @@ if (decided.status === 'decided' && decided.decision === 'Ship it') {
421
517
  }
422
518
  ```
423
519
 
520
+ `status` is `pending`, `decided`, `expired` or `cancelled`. Gate on the exact
521
+ approving `decision`, never on `decision !== 'Hold'`.
522
+
523
+ `waitForDecision()`, `questions.waitForAnswer()` and `handoffs.waitForResult()`
524
+ return only a response for the id you asked about, in a known state. Anything
525
+ else, such as an HTML page from a proxy, `{}`, or another approval, rejects with
526
+ `invalid_response` instead of being returned as an outcome. The wait routes
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
528
+ after its `retryAfter`, and retry network errors and 5xx with a backoff capped
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.
532
+
533
+ The retries are bounded without a signal. Once only failures have come back
534
+ since the last valid response, a wait gives up 5 minutes after the last
535
+ `expires_at` it read, or after 15 minutes of unbroken failures if it has not
536
+ read one yet. It then rejects with code `unavailable`, and the last failure as
537
+ `error.cause`. The CLI uses the same bound. Pass `onRetry(error, attempt)` to
538
+ see each retry (`attempt` counts consecutive failures from 1), and an
539
+ `AbortSignal` to stop sooner. Throwing from `onRetry` ends the wait with that
540
+ error.
541
+
542
+ ```ts
543
+ const decided = await pr.approvals.waitForDecision(approval.id, {
544
+ signal: AbortSignal.timeout(30 * 60_000),
545
+ onRetry: (err, attempt) => {
546
+ if (attempt === 3) console.warn(`PingRoom unreachable (${err.status || err.code}); still waiting`);
547
+ },
548
+ });
549
+ ```
550
+
424
551
  For a runnable example that binds approval to a file's SHA-256, handles timeouts,
425
552
  and refuses a changed artifact, see [release approval](examples/release-approval/README.md).
426
553
  The demo runs locally with simulated decisions and needs no account or credentials.
427
554
 
428
555
  ## Human-in-the-loop questions
429
556
 
430
- A **question** is the general form of an approval: 2–4 options (or a short typed answer), delivered as a push with tappable buttons, resolving to exactly one answer — first tap wins. Reach for `pr.questions` when you need more than yes/no, a typed reply, or want *anyone in the room* to answer.
557
+ A **question** is the general form of an approval: 2–4 options (or a short typed answer), delivered as a push with tappable buttons, resolving to exactly one answer — first tap wins. Reach for `pr.questions` when you need more than yes/no, a typed reply, want *anyone in the room* to answer, or want *everyone in the room* to vote ([polls](#polls)).
431
558
 
432
559
  ```ts
433
560
  // Ask — omit `options` for a binary Approve/Deny; ttl defaults to 1h (30s–24h).
@@ -449,8 +576,8 @@ const answered = await pr.questions.waitForAnswer(q.id);
449
576
 
450
577
  switch (answered.state) {
451
578
  case 'answered':
452
- console.log(`${answered.answer.responder?.display_name} chose ${answered.answer.value}`);
453
- // → 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'
454
581
  break;
455
582
  case 'expired': /* nobody answered in time */ break;
456
583
  case 'cancelled': /* the asker withdrew it */ break;
@@ -467,6 +594,36 @@ await pr.questions.cancel(q.id); // withdraw a pending one
467
594
 
468
595
  > `pr.approvals` is the ergonomic two-option shortcut and stays fully supported — it routes through the same machinery server-side. Use it for plain yes/no gates; use `pr.questions` for everything richer.
469
596
 
597
+ ### Polls
598
+
599
+ Polls need SDK 0.8.0 or newer: 0.7.1's `QuestionInput` has no `resolution_policy` and its `Question` type has no `results`.
600
+
601
+ Set `resolution_policy: 'all'` to poll the room instead of taking the first answer. Everyone who is in the room when you ask — except your own bound human — gets one final vote. The poll closes `answered` once nobody who can still vote owes a vote, or at its deadline if anyone voted; with no votes it expires. Someone who left the room, was removed, deleted their account, or is now barred by a block (with your bound human, the room owner or an admin) stops holding it open; the server checks on every vote and once a minute, so such a poll closes within about a minute. A poll nobody voted in still waits for its deadline.
602
+
603
+ ```ts
604
+ const poll = await pr.questions.ask('ab12cd', {
605
+ prompt: 'Which release name?',
606
+ options: ['otter', 'heron'],
607
+ resolution_policy: 'all', // implies responder_scope: 'room'
608
+ ttl: 3600,
609
+ });
610
+
611
+ const closed = await pr.questions.waitForAnswer(poll.id);
612
+ if (closed.state === 'answered') {
613
+ console.log(closed.answer?.value ?? 'tie'); // the leading option, or null on a tie
614
+ for (const o of closed.results?.options ?? []) console.log(o.value, o.count);
615
+ console.log(`${closed.results?.response_count} of ${closed.results?.audience_count} voted`);
616
+ }
617
+ ```
618
+
619
+ - `results` is on every read of a poll, so `questions.get()` shows the running tally while it is `pending`. It is `null` on a first-answer Question.
620
+ - A poll's `answer` has `responder: null` (the room chose it); who voted what is in `results.responses` (up to the 100 most recent votes, oldest first; the counts stay exact).
621
+ - A closed poll's `answer` is the leader stored when it closed and never changes. `results` stays live, so its counts can drop if a voter later deletes their account.
622
+ - `results.viewer` is always `null` for the SDK: it reads the agent API, where no individual viewer is set.
623
+ - A poll is options-only: `ask()` throws `invalid_request` before sending if you combine `resolution_policy: 'all'` with `responder_scope: 'direct'`, `target_user_id`, or `text_input`.
624
+ - If your bound human is the room's only member, the server asks them a plain `direct` question instead, and the returned Question reads `resolution_policy: 'first'`. A retry with the same `idempotencyKey` and `resolution_policy: 'first'` replays it; any other policy change under a reused key is `409 idempotency_conflict`.
625
+ - A poll nobody in the room could vote in (every member besides your bound human is barred by a block) is refused: `ask()` rejects with a `PingRoomError` whose `status` is 422 (a validation error on `resolution_policy`, with no machine `code`).
626
+
470
627
  ## Handoffs (Agent → one human)
471
628
 
472
629
  A **handoff** is the direct, private version of the human-in-the-loop: it always targets **exactly one human** and is never readable by anyone else in the room. Reach for `pr.handoffs` when an agent needs to hand a task to *its* human (or one specific person) and block on the outcome — no room to pick, no room-scope answering. It's backed by the ack and question primitives, so a handoff is one of two `kind`s:
@@ -518,7 +675,9 @@ callback cannot rewrite the required receipt-before-answer sequence. It throws
518
675
  `answered_without_completion`. Expiry and cancellation throw the same domain
519
676
  error with their corresponding reasons. Caller aborts reject with the signal's
520
677
  reason. None of these paths revoke or change the client's active credential.
521
- 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
522
681
  `pr.inbox.ensure()` directly when you only want to create or inspect the
523
682
  onboarding Question without waiting. Call `activate()` again after an expired,
524
683
  cancelled, or `answered_without_completion` result; the server replays a viable
@@ -553,7 +712,7 @@ if (answered.state === 'answered') {
553
712
  }
554
713
  ```
555
714
 
556
- `waitForResult` loops the bounded long-poll until a terminal state lands, so `expired`/`cancelled` and a negative answer all come back as normal results — nothing is thrown for the outcome. Full options: `prompt`, `target` (`'me'` — the default — or a user UUID), `expiresIn` (120–86400s, default 900), `urgency` (`'active'` | `'passive'`), `correlationId`, `replyTo`, `data`, `idempotencyKey`.
715
+ `waitForResult` loops the bounded long-poll until a terminal state lands, so `expired`/`cancelled` and a negative answer all come back as normal results — nothing is thrown for the outcome. Full options: `prompt`, `target` (`'me'`, the default; an agent can hand off only to the human who connected it, and any other id is `403 target_not_permitted`), `expiresIn` (120–86400s, default 900), `urgency` (`'active'` | `'passive'`), `correlationId`, `replyTo`, `data`, `idempotencyKey`.
557
716
 
558
717
  ```ts
559
718
  await pr.handoffs.get(id); // current state (polling / audit)
@@ -561,6 +720,9 @@ await pr.handoffs.list({ state: 'open' }); // your open handoffs
561
720
  await pr.handoffs.list({ state: 'all' }); // recent history (up to 200 per kind)
562
721
  ```
563
722
 
723
+ `waitForResult` starts polls at least 2.1 seconds apart, so a short `timeout`
724
+ cannot use up the wait route's 30-per-minute limit.
725
+
564
726
  Create/read map coded failures onto `PingRoomError` — branch on `error.code` (see [`HandoffErrorCode`](#errors)). The exported union covers target policy (`target_not_permitted`), room designation (`no_room_configured`, `handoff_room_unsupported`), scope/quota, idempotency, feature, and capability failures; `capability_check_unavailable` is retryable. Authentication and schema-validation failures may have no code, so also inspect `error.status`. `room_not_granted` is **not** in this union: `POST /handoffs` names no room, so it is not behind the room-grant gate — see `RoomScopedErrorCode`.
565
727
 
566
728
  ## Incoming webhooks (no token needed)
@@ -570,7 +732,10 @@ A room's incoming-webhook URL carries its own secret — ideal for CI/deploy hoo
570
732
  ```ts
571
733
  import { sendIncomingWebhook } from '@pingroom/sdk';
572
734
 
573
- 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, {
574
739
  message: 'Build #42 passed',
575
740
  data: { commit: 'abc123' },
576
741
  requires_ack: true,
@@ -583,7 +748,10 @@ await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL, {
583
748
  See [Live Activities](#live-activities) below for the full picture. From a webhook, put `live_status` at the **top level** of the payload:
584
749
 
585
750
  ```ts
586
- 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, {
587
755
  message: 'Deploying v1.4.0',
588
756
  correlation_id: 'deploy-42',
589
757
  live_status: { state: 'running', template: 'progress', progress: 0.4 },
@@ -596,7 +764,7 @@ await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL, {
596
764
 
597
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.
598
766
 
599
- Protocol: <https://pingroom.io/liveactivities.md> (v1.2).
767
+ Protocol: <https://pingroom.io/liveactivities.md> (v1.4).
600
768
 
601
769
  ### Two ways to send one
602
770
 
@@ -613,8 +781,10 @@ await pr.live.push('AB12CD', liveStatus.progress('deploy-42', {
613
781
 
614
782
  // B. A room's incoming webhook (Pro) — no token. The builder returns
615
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.');
616
786
  await sendIncomingWebhook(
617
- process.env.PINGROOM_WEBHOOK_URL,
787
+ webhookUrl,
618
788
  liveStatus.progress('deploy-42', {
619
789
  state: 'running',
620
790
  message: 'Building image',
@@ -632,12 +802,13 @@ There is a third producer — a person driving a stream from the app composer ov
632
802
  | Builder | Renders |
633
803
  |---|---|
634
804
  | `liveStatus.status` | Title, message, optional progress |
635
- | `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 |
636
806
  | `liveStatus.progress` | Determinate bar with optional `eta_at` |
637
807
  | `liveStatus.metrics` | Up to 3 `{label, value}` counters |
638
- | `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 |
639
809
  | `liveStatus.question` | `prompt` + up to 4 `{value, label}` options on the lock screen |
640
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) |
641
812
 
642
813
  ```ts
643
814
  await pr.live.push('AB12CD', liveStatus.steps('release-7', {
@@ -655,6 +826,58 @@ await pr.live.push('AB12CD', liveStatus.matchup('game-3', {
655
826
  }));
656
827
  ```
657
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
+
658
881
  ### Ending and reading back
659
882
 
660
883
  ```ts
@@ -664,23 +887,20 @@ await pr.live.push('AB12CD', liveStatus.done('deploy-42', 'Shipped v1.4.0'));
664
887
  // ...or liveStatus.failed('deploy-42', 'Rollback triggered')
665
888
 
666
889
  // Reconcile after a restart instead of opening a duplicate.
667
- // Returns null for a 404 ONLY — no such stream in the last 24 hours. Every
668
- // other failure throws, including a 403 `room_not_granted`, so a permission
890
+ // Returns null ONLY for a 404 `stream_not_found`: no such stream in the last
891
+ // 24 hours. Every other failure throws, including a 404 for an unknown or
892
+ // deleted room and a 403 `room_not_granted`, so a wrong room or a permission
669
893
  // problem is never mistaken for an absent stream.
670
894
  const snap = await pr.live.get('AB12CD', 'deploy-42');
671
895
  if (snap) console.log(snap.template, snap.current_step, snap.updated_at);
672
896
  ```
673
897
 
674
- `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`.
675
899
 
676
900
  ### Ownership and limits
677
901
 
678
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.
679
903
 
680
- ### `liveActivity.*` is not this
681
-
682
- 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.
683
-
684
904
  ## Verifying outgoing webhooks
685
905
 
686
906
  When PingRoom POSTs an event to *your* server, verify it before trusting it. New deliveries carry two lower-case hex HMAC-SHA256 headers:
@@ -690,6 +910,8 @@ When PingRoom POSTs an event to *your* server, verify it before trusting it. New
690
910
 
691
911
  `timestamp` is the Unix-seconds value in `X-PingRoom-Timestamp`, and `deliveryId` is the exact `X-PingRoom-Delivery` value. **Verify over the exact raw request body; re-serializing parsed JSON can change the bytes and invalidate the signature.** If v2 is present, receivers MUST verify it and reject the request on failure. The helper accepts legacy v1 only when v2 is absent. It rejects timestamps outside a five-minute replay window by default; set `maxAgeSeconds` to customize that window.
692
912
 
913
+ Pass `requireV2: true`. Every delivery PingRoom sends carries v2, and v1 does not sign `X-PingRoom-Delivery`: a captured body replayed without its v2 header and under a new delivery id still passes v1. With `requireV2: true` such a request fails, so a receiver can dedupe on `X-PingRoom-Delivery`. Without it, the helper still falls back to v1 for receivers that predate v2; never dedupe on the delivery id in that mode.
914
+
693
915
  ```ts
694
916
  import {
695
917
  verifyWebhookSignature,
@@ -699,15 +921,21 @@ import {
699
921
  WEBHOOK_DELIVERY_HEADER,
700
922
  } from '@pingroom/sdk';
701
923
 
702
- // Express example — capture the raw body (e.g. express.raw())
703
- app.post('/pingroom', async (req, res) => {
924
+ const signingSecret = process.env.PINGROOM_SIGNING_SECRET;
925
+ if (!signingSecret) throw new Error('Set PINGROOM_SIGNING_SECRET to the webhook signing_secret.');
926
+
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) => {
704
931
  const ok = await verifyWebhookSignature({
705
- payload: req.body, // a Buffer/string of the RAW body
932
+ payload: req.body, // a Buffer of the RAW body
706
933
  signature: req.get(WEBHOOK_SIGNATURE_HEADER),
707
934
  signatureV2: req.get(WEBHOOK_SIGNATURE_V2_HEADER),
708
935
  timestamp: req.get(WEBHOOK_TIMESTAMP_HEADER),
709
936
  deliveryId: req.get(WEBHOOK_DELIVERY_HEADER),
710
- secret: process.env.PINGROOM_SIGNING_SECRET,
937
+ secret: signingSecret,
938
+ requireV2: true, // refuse the legacy v1-only form
711
939
  });
712
940
  if (!ok) return res.status(401).end();
713
941
  // ... handle the verified event
@@ -715,6 +943,13 @@ app.post('/pingroom', async (req, res) => {
715
943
  });
716
944
  ```
717
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
+
718
953
  ## Redeem a gift or promotional code
719
954
 
720
955
  ```ts
@@ -761,20 +996,28 @@ const profile = await pr.directory.get('agt_deploybell');
761
996
 
762
997
  ## Errors
763
998
 
764
- Every failure rejects with a `PingRoomError` carrying the HTTP `status` and the API's machine `code` (e.g. `cooldown`, `rate_limited`, `room_not_granted`), plus `retryAfter` when present:
999
+ Every failure rejects with a `PingRoomError` carrying the HTTP `status` and the API's machine `code` (e.g. `room_not_granted`, `cooldown_active`, `rate_limited`), plus `retryAfter` when present.
1000
+
1001
+ Retry on the status, not on one code. Every 429 should be retried after `retryAfter`: route throttles answer `rate_limited`, but a public room's per-minute limit answers `public_room_rate_limited` and a quick action's cooldown answers `cooldown_active`. Reuse the same `idempotencyKey` on the retry:
765
1002
 
766
1003
  ```ts
767
1004
  import { PingRoomError } from '@pingroom/sdk';
768
1005
 
1006
+ const send = () => pr.broadcast('ab12cd', { message: 'hi', idempotencyKey: 'hello-1' });
769
1007
  try {
770
- await pr.broadcast('ab12cd', { message: 'hi' });
1008
+ await send();
771
1009
  } catch (err) {
772
- if (err instanceof PingRoomError && err.code === 'rate_limited') {
1010
+ if (err instanceof PingRoomError && err.status === 429) {
773
1011
  await new Promise((r) => setTimeout(r, (err.retryAfter ?? 1) * 1000));
1012
+ await send();
1013
+ } else {
1014
+ throw err;
774
1015
  }
775
1016
  }
776
1017
  ```
777
1018
 
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.
1020
+
778
1021
  The coded vocabulary is exported both as types and as runtime arrays, one per
779
1022
  surface, so you can branch exhaustively:
780
1023
 
@@ -783,9 +1026,10 @@ surface, so you can branch exhaustively:
783
1026
  | `HandoffErrorCode` / `HANDOFF_ERROR_CODES` | `handoffs.*` |
784
1027
  | `RoomScopedErrorCode` / `ROOM_SCOPED_ERROR_CODES` | every call whose path names a room (`rooms.get`, `actions.*`, `broadcast`, `webhooks.*`, `live.*`) |
785
1028
  | `AgentInboxErrorCode` / `AGENT_INBOX_ERROR_CODES` | `inbox.ensure()` / `inbox.activate()` |
786
- | `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) |
787
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) |
788
- | `AgentErrorCode` | all five, for one shared branch |
1032
+ | `AgentErrorCode` | all six, for one shared branch |
789
1033
 
790
1034
  ## Configuration
791
1035
 
@@ -793,7 +1037,7 @@ surface, so you can branch exhaustively:
793
1037
  new PingRoom({
794
1038
  token: '...', // agent credential (optional for public reads)
795
1039
  baseUrl: 'https://api.pingroom.io', // or env PINGROOM_API_URL / PINGROOM_BASE_URL
796
- 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
797
1041
  fetch: customFetch, // inject a fetch (tests / non-global-fetch runtimes)
798
1042
  allowInsecure: false, // allow plain-http base URLs (off by default)
799
1043
  });