@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/dist/client.js CHANGED
@@ -1,1310 +1,27 @@
1
- import { PingRoomActivationIncompleteError, PingRoomError } from './errors.js';
2
1
  import { HttpClient } from './http.js';
3
- import { combineSignals, sleep, throwIfAborted } from './internal/async.js';
4
- import { assertActionNumber, assertMaxLength, assertStructuredData, MAX_PING_TITLE_LENGTH, MAX_PUBLIC_PING_MESSAGE_LENGTH, normalizeIdempotencyKey, normalizeRedeemCode, requireNonEmpty, requirePresentString, } from './internal/guards.js';
5
- import { isTransientError, pollUntilSettled, transientDelayMs } from './internal/retry.js';
6
- import { linkPing } from './linkPing.js';
7
- import { locationPing } from './locationPing.js';
2
+ import { normalizeRedeemCode } from './internal/guards.js';
8
3
  import { McpClient } from './mcp.js';
4
+ import { enc, splitIdempotencyKey, dropUndefined, assertPing } from './internal/request.js';
5
+ import { AuthApi } from './namespaces/auth.js';
6
+ import { RoomsApi } from './namespaces/rooms.js';
7
+ import { ActionsApi } from './namespaces/actions.js';
8
+ import { WebhooksApi } from './namespaces/webhooks.js';
9
+ import { NotificationsApi } from './namespaces/notifications.js';
10
+ import { AttachmentsApi } from './namespaces/attachments.js';
11
+ import { ApprovalsApi } from './namespaces/approvals.js';
12
+ import { QuestionsApi } from './namespaces/questions.js';
13
+ import { HandoffsApi } from './namespaces/handoffs.js';
14
+ import { InboxApi } from './namespaces/inbox.js';
15
+ import { ProfileApi } from './namespaces/profile.js';
16
+ import { LiveApi } from './namespaces/live.js';
17
+ import { DirectoryApi } from './namespaces/directory.js';
9
18
  const DEFAULT_BASE_URL = 'https://api.pingroom.io';
10
19
  /** Node's util.inspect hook, named through the global registry so no import is needed. */
11
20
  const INSPECT = Symbol.for('nodejs.util.inspect.custom');
12
- /** Server cap per uploaded file; a larger body is a 413 the server answers before reading it. */
13
- const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
14
- const MAX_TRIGGER_ATTACHMENTS = 4;
15
- const QUICK_ACTION_PAGE_SIZE = 4;
16
- const MAX_QUICK_ACTION_PAGES = 4;
17
- /** Extra wall-clock allowance over the server's hold window for long-poll calls. */
18
- const LONG_POLL_BUFFER_MS = 10_000;
19
- const DEFAULT_WAIT_SECONDS = 20;
20
- const DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS = 120_000;
21
- /**
22
- * Minimum spacing between the starts of two handoff-wait polls. The route is
23
- * throttled at 30 requests a minute per human; a short or zero `timeout`
24
- * would otherwise let one loop exhaust it.
25
- */
26
- const MIN_HANDOFF_POLL_INTERVAL_MS = 2100;
27
- /**
28
- * Minimum spacing between the starts of two approval or question wait polls.
29
- * The server normally holds each one, but a proxy or a zero hold that answers
30
- * `pending` at once would otherwise spin the loop and keep the human's shared
31
- * 30-per-minute wait bucket exhausted. Same floor as the CLI.
32
- */
33
- const MIN_WAIT_POLL_INTERVAL_MS = 1000;
34
21
  function envBaseUrl() {
35
22
  const proc = globalThis.process;
36
23
  return proc?.env?.['PINGROOM_API_URL'] || proc?.env?.['PINGROOM_BASE_URL'] || undefined;
37
24
  }
38
- function enc(segment) {
39
- // encodeURIComponent leaves `.` and `..` alone, and URL parsing then
40
- // resolves them as dot segments: `live.get('AB12', '..')` would request the
41
- // room itself. No id is empty or a dot segment, so refuse them.
42
- if (segment === '' || segment === '.' || segment === '..') {
43
- throw new PingRoomError('A path parameter may not be empty, `.` or `..`.', { code: 'invalid_request' });
44
- }
45
- return encodeURIComponent(segment);
46
- }
47
- /**
48
- * Lift the SDK-only `idempotencyKey` out of an input and into the
49
- * `Idempotency-Key` header the server reads. The server trims the key and
50
- * accepts 1–255 characters; as a header it must also be printable ASCII.
51
- */
52
- function splitIdempotencyKey(input) {
53
- const { idempotencyKey, ...body } = input;
54
- if (idempotencyKey === undefined) {
55
- return { body };
56
- }
57
- return { body, headers: { 'Idempotency-Key': normalizeIdempotencyKey(idempotencyKey) } };
58
- }
59
- /** `onRetry`, when given, must be callable: a TypeError mid-outage would hide the real failure. */
60
- function assertOnRetry(onRetry) {
61
- if (onRetry !== undefined && typeof onRetry !== 'function') {
62
- throw new PingRoomError('`onRetry` must be a function.', { code: 'invalid_request' });
63
- }
64
- }
65
- /**
66
- * The per-slot checks shared by `updateMany()` and `updateLayout()`: a valid,
67
- * present, unique action_number and an icon whenever the slot has a label.
68
- * Two entries for one slot would let the last silently win, and the caller
69
- * would never learn which of its definitions was stored.
70
- */
71
- function assertActionSlots(actions) {
72
- const seen = new Set();
73
- for (const action of actions) {
74
- assertActionNumber(action.action_number);
75
- if (action.action_number === undefined || action.action_number === null) {
76
- throw new PingRoomError('`action_number` is required for each action.', { code: 'invalid_request' });
77
- }
78
- if (seen.has(action.action_number)) {
79
- throw new PingRoomError(`Duplicate action_number ${action.action_number}.`, { code: 'invalid_request' });
80
- }
81
- seen.add(action.action_number);
82
- assertQuickActionIcon(action);
83
- }
84
- }
85
- function dropUndefined(obj) {
86
- const out = {};
87
- for (const [k, v] of Object.entries(obj)) {
88
- if (v !== undefined) {
89
- out[k] = v;
90
- }
91
- }
92
- return out;
93
- }
94
- /**
95
- * Mirrors the server's `icon` rule (`present`, `required_with:label`): the emoji
96
- * is the half that must be there unless BOTH are empty, which reserves the
97
- * slot as disabled.
98
- */
99
- function assertQuickActionIcon(action) {
100
- requirePresentString(action.label, 'label');
101
- requirePresentString(action.icon, 'icon');
102
- if (action.icon.trim() === '' && action.label.trim() !== '') {
103
- throw new PingRoomError('`icon` is required when `label` is set; send both empty to disable the slot.', {
104
- code: 'invalid_request',
105
- });
106
- }
107
- }
108
- function assertTriggerInput(input) {
109
- const data = input.data;
110
- if (data !== undefined) {
111
- if (data === null || typeof data !== 'object' || Array.isArray(data)) {
112
- throw new PingRoomError('`data` must be an object with `location` and/or `url`.', { code: 'invalid_request' });
113
- }
114
- for (const key of Object.keys(data)) {
115
- if (key !== 'location' && key !== 'url') {
116
- throw new PingRoomError(`\`data.${key}\` is not accepted on a quick-action press; only location and url are.`, {
117
- code: 'invalid_request',
118
- });
119
- }
120
- }
121
- try {
122
- if (data.location !== undefined)
123
- locationPing(data.location);
124
- if (data.url !== undefined)
125
- linkPing({ url: data.url });
126
- }
127
- catch (error) {
128
- throw new PingRoomError(error.message, { code: 'invalid_request' });
129
- }
130
- }
131
- if (input.attachment_ids !== undefined) {
132
- if (!Array.isArray(input.attachment_ids) || input.attachment_ids.length > MAX_TRIGGER_ATTACHMENTS) {
133
- throw new PingRoomError(`\`attachment_ids\` accepts at most ${MAX_TRIGGER_ATTACHMENTS} ids.`, { code: 'invalid_request' });
134
- }
135
- for (const id of input.attachment_ids)
136
- requireNonEmpty(id, 'attachment_ids[]');
137
- }
138
- }
139
- function attachmentByteLength(content) {
140
- if (content instanceof Blob)
141
- return content.size;
142
- if (typeof content === 'string')
143
- return new TextEncoder().encode(content).byteLength;
144
- return content.byteLength;
145
- }
146
- function assertPing(ping) {
147
- requireNonEmpty(ping.message, 'message');
148
- // The invite code does not expose room visibility. Permit the public-room
149
- // ceiling locally; Laravel applies 120 for private and 160 for public rooms.
150
- assertMaxLength(ping.message, MAX_PUBLIC_PING_MESSAGE_LENGTH, 'message');
151
- assertMaxLength(ping.title, MAX_PING_TITLE_LENGTH, 'title');
152
- assertActionNumber(ping.action_number);
153
- assertStructuredData(ping.data);
154
- }
155
- function waitTimeoutMs(seconds) {
156
- return (seconds ?? DEFAULT_WAIT_SECONDS) * 1000 + LONG_POLL_BUFFER_MS;
157
- }
158
- function isRecord(value) {
159
- return value !== null && typeof value === 'object' && !Array.isArray(value);
160
- }
161
- function isNonEmptyString(value) {
162
- return typeof value === 'string' && value.trim() !== '';
163
- }
164
- function isNullableString(value) {
165
- return value === null || typeof value === 'string';
166
- }
167
- /** Drop a malformed or unsafe server-provided URL without rejecting its response. */
168
- function normalizePublicHttpUrl(candidate) {
169
- if (typeof candidate !== 'string' || candidate.trim() === '')
170
- return undefined;
171
- const trimmed = candidate.trim();
172
- // eslint-disable-next-line no-control-regex
173
- if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed))
174
- return undefined;
175
- try {
176
- const url = new URL(trimmed);
177
- if (url.protocol !== 'https:' && url.protocol !== 'http:')
178
- return undefined;
179
- if (url.username !== '' || url.password !== '')
180
- return undefined;
181
- }
182
- catch {
183
- return undefined;
184
- }
185
- return trimmed;
186
- }
187
- /** Drop malformed or unsafe server-provided links without rejecting a valid credential. */
188
- function normalizePairingLinks(value) {
189
- if (!isRecord(value))
190
- return undefined;
191
- const latestPings = normalizePublicHttpUrl(value['latest_pings']);
192
- if (!latestPings)
193
- return undefined;
194
- const installApp = normalizePublicHttpUrl(value['install_app']);
195
- return {
196
- latest_pings: latestPings,
197
- ...(installApp ? { install_app: installApp } : {}),
198
- };
199
- }
200
- function invalidInboxResponse(message, body = {}) {
201
- throw new PingRoomError(message, { code: 'inbox_invalid_response', body });
202
- }
203
- function validateInboxEnsure(value) {
204
- if (!isRecord(value) || value['onboarded'] !== true || typeof value['replayed'] !== 'boolean') {
205
- return invalidInboxResponse('PingRoom returned an invalid Agent Inbox ensure response.');
206
- }
207
- const room = value['room'];
208
- const question = value['question'];
209
- if (!isRecord(room)
210
- || !isNonEmptyString(room['id'])
211
- || typeof room['name'] !== 'string'
212
- || !isNonEmptyString(room['invite_code'])
213
- || typeof room['is_agent_inbox'] !== 'boolean'
214
- || !isRecord(question)
215
- || !isNonEmptyString(question['id'])
216
- || question['kind'] !== 'question'
217
- || !isNonEmptyString(question['prompt'])
218
- || !Array.isArray(question['options'])
219
- || question['options'].some((option) => (!isRecord(option)
220
- || !isNonEmptyString(option['value'])
221
- || !isNonEmptyString(option['label'])))
222
- || (question['state'] !== 'pending'
223
- && question['state'] !== 'answered'
224
- && question['state'] !== 'expired'
225
- && question['state'] !== 'cancelled')
226
- || !isNullableString(question['expires_at'])
227
- || !isNullableString(question['created_at'])) {
228
- return invalidInboxResponse('PingRoom returned an incomplete Agent Inbox ensure response.');
229
- }
230
- return value;
231
- }
232
- function validateInboxWait(value, expectedId) {
233
- if (!isRecord(value)) {
234
- return invalidInboxResponse('PingRoom returned an invalid Agent Inbox wait response.');
235
- }
236
- const state = value['state'];
237
- if (!isNonEmptyString(value['id'])
238
- || value['id'] !== expectedId
239
- || value['kind'] !== 'question'
240
- || (state !== 'pending' && state !== 'answered' && state !== 'expired' && state !== 'cancelled')
241
- || (value['activation_completed'] !== undefined && typeof value['activation_completed'] !== 'boolean')
242
- || (state !== 'answered' && value['activation_completed'] === true)) {
243
- return invalidInboxResponse('PingRoom returned a mismatched Agent Inbox wait response.', {
244
- expected_id: expectedId,
245
- received_id: typeof value['id'] === 'string' ? value['id'] : null,
246
- kind: typeof value['kind'] === 'string' ? value['kind'] : null,
247
- state: typeof state === 'string' ? state : null,
248
- });
249
- }
250
- if (state === 'answered') {
251
- const answer = value['answer'];
252
- if (!isRecord(answer)
253
- || !isNullableString(answer['value'])
254
- || !isNullableString(answer['label'])
255
- || !isNullableString(answer['text'])
256
- || (!isNonEmptyString(answer['value']) && !isNonEmptyString(answer['text']))
257
- || !isNullableString(answer['answered_at'])
258
- || (answer['responder'] !== null && !isRecord(answer['responder']))) {
259
- return invalidInboxResponse('PingRoom returned an answered activation without a valid answer.', {
260
- id: expectedId,
261
- state,
262
- });
263
- }
264
- const responder = answer['responder'];
265
- if (isRecord(responder)
266
- && (!isNullableString(responder['id']) || !isNullableString(responder['display_name']))) {
267
- return invalidInboxResponse('PingRoom returned an invalid activation responder.', {
268
- id: expectedId,
269
- state,
270
- });
271
- }
272
- }
273
- else if (value['answer'] !== undefined && value['answer'] !== null) {
274
- return invalidInboxResponse('PingRoom returned an answer for an unresolved activation.', {
275
- id: expectedId,
276
- state,
277
- });
278
- }
279
- return value;
280
- }
281
- /** UUIDs are case-insensitive; a caller may hold an id in either case. */
282
- function sameId(received, expected) {
283
- return typeof received === 'string' && received.toLowerCase() === expected.toLowerCase();
284
- }
285
- function invalidWaitResponse(subject, expectedId, value, stateField) {
286
- throw new PingRoomError(`PingRoom returned an invalid ${subject} wait response.`, {
287
- code: 'invalid_response',
288
- body: {
289
- expected_id: expectedId,
290
- received_id: isRecord(value) && typeof value['id'] === 'string' ? value['id'] : null,
291
- [stateField]: isRecord(value) && typeof value[stateField] === 'string' ? value[stateField] : null,
292
- },
293
- });
294
- }
295
- const APPROVAL_STATUSES = new Set(['pending', 'decided', 'expired', 'cancelled']);
296
- const QUESTION_STATES = new Set(['pending', 'answered', 'expired', 'cancelled']);
297
- const HANDOFF_STATES = new Set(['open', 'acked', 'pending', 'answered', 'expired', 'cancelled']);
298
- /**
299
- * A wait response is trusted as an outcome only when it is the approval that
300
- * was asked about, in a known status. A captive portal's HTML, `{}`, or another
301
- * approval must not read as "decided".
302
- */
303
- function validateApprovalWait(value, approvalId) {
304
- const status = isRecord(value) ? value['status'] : undefined;
305
- if (!isRecord(value)
306
- || !sameId(value['id'], approvalId)
307
- || typeof status !== 'string'
308
- || !APPROVAL_STATUSES.has(status)
309
- || !isNullableString(value['decided_at'])
310
- // A gate acts on `decision`; a decided approval without one, or a pending
311
- // one already stamped decided, is not an outcome it can act on.
312
- || (status === 'decided' && !isNonEmptyString(value['decision']))
313
- || (status === 'pending' && value['decided_at'] !== null)) {
314
- return invalidWaitResponse('approval', approvalId, value, 'status');
315
- }
316
- return value;
317
- }
318
- function validateQuestionWait(value, questionId) {
319
- const state = isRecord(value) ? value['state'] : undefined;
320
- if (!isRecord(value) || !sameId(value['id'], questionId) || typeof state !== 'string' || !QUESTION_STATES.has(state)) {
321
- return invalidWaitResponse('question', questionId, value, 'state');
322
- }
323
- return value;
324
- }
325
- function validateHandoffWait(value, handoffId) {
326
- const state = isRecord(value) ? value['state'] : undefined;
327
- if (!isRecord(value) || !sameId(value['id'], handoffId) || typeof state !== 'string' || !HANDOFF_STATES.has(state)) {
328
- return invalidWaitResponse('handoff', handoffId, value, 'state');
329
- }
330
- return value;
331
- }
332
- // --- namespaces -----------------------------------------------------------
333
- /** Agent credential lifecycle (auth.md flow). */
334
- class AuthApi {
335
- http;
336
- constructor(http) {
337
- this.http = http;
338
- }
339
- /** Register an agent. Anonymous or via an identity assertion (ID-JAG / verified email). Public. */
340
- register(params) {
341
- requireNonEmpty(params.type, 'type');
342
- return this.http.request('POST', '/api/agent/auth', { auth: false, body: dropUndefined({ ...params }) });
343
- }
344
- /** Begin claiming a pre-claim credential onto a human account; emails an OTP. */
345
- claimStart(params) {
346
- requireNonEmpty(params.email, 'email');
347
- return this.http.request('POST', '/api/agent/auth/claim/start', { body: { email: params.email } });
348
- }
349
- /** Complete the claim with the emailed OTP; returns an active credential + handle. */
350
- claimComplete(params) {
351
- requireNonEmpty(params.email, 'email');
352
- requireNonEmpty(params.otp, 'otp');
353
- return this.http.request('POST', '/api/agent/auth/claim/complete', { body: dropUndefined({ ...params }) });
354
- }
355
- /**
356
- * Register a pending agent and create a short-lived link that opens the
357
- * PingRoom app. The server owns the full grant shown to the approver; this
358
- * client sends no scope list. The pending credential stays private inside the
359
- * client and is replaced automatically once waitForPairing() succeeds.
360
- */
361
- async startPairing(params = {}) {
362
- const previousToken = this.http.getToken();
363
- const pending = await this.register({
364
- type: 'anonymous',
365
- ...(params.agent_label ? { agent_label: params.agent_label } : {}),
366
- });
367
- this.http.setToken(pending.credential);
368
- try {
369
- return await this.http.request('POST', '/api/agent/auth/pair/start', {
370
- body: {},
371
- });
372
- }
373
- catch (error) {
374
- if (this.http.getToken() === pending.credential) {
375
- this.http.setToken(previousToken);
376
- }
377
- throw error;
378
- }
379
- }
380
- /** Read the current state of an app pairing using the pending credential. */
381
- pairingStatus(options = {}) {
382
- return this.http.request('GET', '/api/agent/auth/pair/status', { signal: options.signal });
383
- }
384
- /**
385
- * Wait until the person approves in PingRoom, or the short-lived link
386
- * expires. Transient network, 429, and 5xx failures are retried inside the
387
- * server-provided lifetime. On success the client immediately starts using
388
- * the active credential returned by the server.
389
- */
390
- async waitForPairing(pairing, options = {}) {
391
- const lifetimeMs = Math.max(1, Number(pairing.expires_in) || 900) * 1000;
392
- const requestedInterval = options.pollIntervalMs ?? pairing.poll_interval_ms;
393
- const intervalMs = Math.min(Math.max(Number(requestedInterval) || 1500, 1000), 10_000);
394
- const startedAt = Date.now();
395
- const deadline = startedAt + lifetimeMs;
396
- let transientFailures = 0;
397
- while (Date.now() < deadline) {
398
- throwIfAborted(options.signal);
399
- try {
400
- const status = await this.pairingStatus({ signal: options.signal });
401
- throwIfAborted(options.signal);
402
- transientFailures = 0;
403
- if (status.status === 'active') {
404
- if (typeof status.credential !== 'string'
405
- || status.credential.trim() === ''
406
- || status.credential_type !== 'active'
407
- || (status.expires_in !== null
408
- && (typeof status.expires_in !== 'number'
409
- || !Number.isFinite(status.expires_in)
410
- || status.expires_in < 0))
411
- // A null/absent `room` is valid when the account has no eligible
412
- // private delivery room (and on older servers). Only a room that is
413
- // present but malformed is a broken response.
414
- || (status.room != null
415
- && (typeof status.room.invite_code !== 'string'
416
- || status.room.invite_code.trim() === ''))) {
417
- throw new PingRoomError('PingRoom returned an incomplete approved pairing.', {
418
- code: 'pairing_invalid_response',
419
- body: { status: status.status },
420
- });
421
- }
422
- this.http.setToken(status.credential);
423
- // Pairing is server-authoritative. Some servers still include their
424
- // internal grant marker for protocol compatibility; it is neither a
425
- // client input nor part of the paired credential surface.
426
- const { status: _status, scopes: _serverScopes, links: rawLinks, ...credential } = status;
427
- const links = normalizePairingLinks(rawLinks);
428
- return links ? { ...credential, links } : credential;
429
- }
430
- if (status.status === 'expired') {
431
- throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
432
- code: 'pairing_expired',
433
- });
434
- }
435
- }
436
- catch (error) {
437
- if (error instanceof PingRoomError
438
- && [401, 403, 404].includes(error.status)
439
- && Date.now() - startedAt >= lifetimeMs / 2) {
440
- // The pending bearer can expire before the server returns an
441
- // explicit expired pairing state. Keep early auth failures intact.
442
- throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
443
- code: 'pairing_expired',
444
- });
445
- }
446
- if (error instanceof PingRoomError
447
- && (error.code === 'network_error'
448
- || error.code === 'timeout'
449
- || error.status === 429
450
- || error.status >= 500)) {
451
- transientFailures += 1;
452
- const backoffMs = Math.min(intervalMs * 2 ** Math.max(0, transientFailures - 3), 30_000);
453
- await sleep(Math.max(0, Math.min(backoffMs, deadline - Date.now())), options.signal);
454
- continue;
455
- }
456
- throw error;
457
- }
458
- await sleep(Math.max(0, Math.min(intervalMs, deadline - Date.now())), options.signal);
459
- }
460
- throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
461
- code: 'pairing_expired',
462
- });
463
- }
464
- /** Rotate to a fresh credential (same scopes/handle). */
465
- refresh() {
466
- return this.http.request('POST', '/api/agent/auth/refresh', { body: {} });
467
- }
468
- /** Revoke the current credential. */
469
- revoke() {
470
- return this.http.request('POST', '/api/agent/auth/revoke', { body: {} });
471
- }
472
- /**
473
- * What this credential is connected to: the robot, the owner's public id and
474
- * name, the home room and room memberships, and the room grant. Check
475
- * `owner.id` and `handle` against the account and robot you approved before
476
- * sending anything.
477
- */
478
- connection() {
479
- return this.http.request('GET', '/api/agent/connection');
480
- }
481
- /**
482
- * Disconnect this agent: revokes the calling credential and its refresh
483
- * credentials. The client keeps the now-revoked token; clear it with
484
- * `setToken(null)` or pair again.
485
- */
486
- disconnect() {
487
- return this.http.request('POST', '/api/agent/disconnect', { body: {} });
488
- }
489
- }
490
- class RoomsApi {
491
- http;
492
- constructor(http) {
493
- this.http = http;
494
- }
495
- icons() {
496
- return this.http.request('GET', '/api/agent/room-icons');
497
- }
498
- list() {
499
- return this.http.request('GET', '/api/agent/rooms');
500
- }
501
- get(inviteCode) {
502
- return this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}`);
503
- }
504
- create(input) {
505
- requireNonEmpty(input.name, 'name');
506
- return this.http.request('POST', '/api/agent/rooms', { body: dropUndefined({ ...input }) });
507
- }
508
- createPublic(input) {
509
- requireNonEmpty(input.name, 'name');
510
- requireNonEmpty(input.handle, 'handle');
511
- // The server takes the location trio all-or-nothing; a partial set is a
512
- // 422 on a field the caller may not have meant to send at all.
513
- const present = [input.location_name, input.location_latitude, input.location_longitude].filter((v) => v !== undefined).length;
514
- if (present !== 0 && present !== 3) {
515
- throw new PingRoomError('Send `location_name`, `location_latitude` and `location_longitude` together, or none of them.', {
516
- code: 'invalid_request',
517
- });
518
- }
519
- return this.http.request('POST', '/api/agent/rooms/public', { body: dropUndefined({ ...input }) });
520
- }
521
- join(input) {
522
- requireNonEmpty(input.invite_code, 'invite_code');
523
- return this.http.request('POST', '/api/agent/rooms/join', { body: dropUndefined({ ...input }) });
524
- }
525
- }
526
- class ActionsApi {
527
- http;
528
- constructor(http) {
529
- this.http = http;
530
- }
531
- list(inviteCode) {
532
- return this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/actions`);
533
- }
534
- update(inviteCode, actionNumber, input) {
535
- assertActionNumber(actionNumber);
536
- // A Ping's title is optional — its emoji can be the whole name — so an
537
- // empty `label` is a valid update. `icon` is the half that must be there,
538
- // unless both are empty: that reserves the slot as disabled.
539
- assertQuickActionIcon(input);
540
- return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}`, {
541
- body: dropUndefined({ ...input }),
542
- });
543
- }
544
- /**
545
- * Write up to 16 action slots in one request. The server enforces the room owner's plan.
546
- * Add new pages in complete groups of four; existing slots may be edited individually.
547
- *
548
- * Prefer this over looping `update()`. Each single-slot write enqueues its
549
- * own silent rooms-refresh push, so configuring a room's four Pings one at a
550
- * time wakes room members' devices four times inside a few seconds — four of a
551
- * finite daily background-push budget spent on one logical operation.
552
- *
553
- * Partial and additive: slots absent from `actions` keep their current
554
- * configuration, so this is also the right call for editing a single Ping.
555
- * Nothing here deletes an action, and an empty array is rejected locally
556
- * rather than being sent as a clear. Resolves to the room's full ordered
557
- * action set, not just the slots written.
558
- */
559
- updateMany(inviteCode, actions) {
560
- if (!Array.isArray(actions) || actions.length === 0) {
561
- throw new PingRoomError('`actions` must contain at least one action.', { code: 'invalid_request' });
562
- }
563
- if (actions.length > 16) {
564
- throw new PingRoomError('A batch may contain at most 16 action slots.', { code: 'invalid_request' });
565
- }
566
- assertActionSlots(actions);
567
- return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions`, {
568
- body: { actions: actions.map((action) => dropUndefined({ ...action })) },
569
- });
570
- }
571
- /**
572
- * Press a slot. When the action has an `input_type`, pass the matching
573
- * detail (`data.location`, `data.url`, or `attachment_ids`) or the server
574
- * refuses the press with `422 quick_action_input_required` and sends nothing.
575
- * Read `input_type` from `list()` first; pass the action's `id` as
576
- * `quick_action_id` to be refused rather than fire the wrong Ping if the
577
- * owner moved pages meanwhile.
578
- */
579
- trigger(inviteCode, actionNumber, input = {}) {
580
- assertActionNumber(actionNumber);
581
- assertTriggerInput(input);
582
- const { body, headers } = splitIdempotencyKey(input);
583
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}/trigger`, {
584
- body: dropUndefined({ ...body }),
585
- ...(headers ? { headers } : {}),
586
- });
587
- }
588
- /**
589
- * Replace the room's page layout atomically (room owner on Pro only). See
590
- * `UpdateQuickActionLayoutInput` for the snapshot contract; a stale
591
- * `base_action_ids` is `409 quick_action_layout_changed`, a page still used
592
- * by a time trigger, webhook, agent binding or running live update is
593
- * `409 quick_action_page_in_use`. Prefer `deletePage()`, which builds the
594
- * body from a fresh read.
595
- */
596
- updateLayout(inviteCode, input) {
597
- if (!Array.isArray(input.base_action_ids)) {
598
- throw new PingRoomError('`base_action_ids` must be an array of action ids (may be empty).', { code: 'invalid_request' });
599
- }
600
- if (!Array.isArray(input.page_order) || input.page_order.length < 1 || input.page_order.length > MAX_QUICK_ACTION_PAGES) {
601
- throw new PingRoomError(`\`page_order\` must list 1–${MAX_QUICK_ACTION_PAGES} pages.`, { code: 'invalid_request' });
602
- }
603
- for (const page of input.page_order) {
604
- if (page !== null && (!Number.isInteger(page) || page < 1 || page > MAX_QUICK_ACTION_PAGES)) {
605
- throw new PingRoomError('`page_order` entries must be an original page number 1–4 or null for a blank page.', {
606
- code: 'invalid_request',
607
- });
608
- }
609
- }
610
- const expected = input.page_order.length * QUICK_ACTION_PAGE_SIZE;
611
- if (!Array.isArray(input.actions) || input.actions.length !== expected) {
612
- throw new PingRoomError(`\`actions\` must hold exactly ${expected} slots for ${input.page_order.length} page(s).`, {
613
- code: 'invalid_request',
614
- });
615
- }
616
- assertActionSlots(input.actions);
617
- return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/layout`, {
618
- body: {
619
- base_action_ids: input.base_action_ids,
620
- page_order: input.page_order,
621
- actions: input.actions.map((action) => dropUndefined({ ...action })),
622
- },
623
- });
624
- }
625
- /**
626
- * Delete one page (1–4) and shift the later pages left, keeping every other
627
- * slot's configuration. Reads the current action set first so the layout
628
- * snapshot is real, then calls `updateLayout()`. Page 1 can be deleted only
629
- * when another page remains.
630
- */
631
- async deletePage(inviteCode, page) {
632
- if (!Number.isInteger(page) || page < 1 || page > MAX_QUICK_ACTION_PAGES) {
633
- throw new PingRoomError('`page` must be an integer 1–4.', { code: 'invalid_request' });
634
- }
635
- const current = await this.list(inviteCode);
636
- const stored = [...current].sort((a, b) => a.action_number - b.action_number);
637
- const pageCount = Math.max(1, Math.ceil((stored.at(-1)?.action_number ?? 0) / QUICK_ACTION_PAGE_SIZE));
638
- if (page > pageCount) {
639
- throw new PingRoomError(`The room has ${pageCount} page(s); page ${page} does not exist.`, { code: 'invalid_request' });
640
- }
641
- if (pageCount === 1) {
642
- throw new PingRoomError('A room keeps at least one page; configure its slots instead of deleting it.', {
643
- code: 'invalid_request',
644
- });
645
- }
646
- const keep = Array.from({ length: pageCount }, (_, i) => i + 1).filter((p) => p !== page);
647
- const byNumber = new Map(stored.map((a) => [a.action_number, a]));
648
- const actions = [];
649
- keep.forEach((originalPage, index) => {
650
- for (let offset = 1; offset <= QUICK_ACTION_PAGE_SIZE; offset++) {
651
- const source = byNumber.get((originalPage - 1) * QUICK_ACTION_PAGE_SIZE + offset);
652
- actions.push({
653
- action_number: index * QUICK_ACTION_PAGE_SIZE + offset,
654
- label: source?.label ?? '',
655
- icon: source?.icon ?? '',
656
- ...(source?.sound !== undefined ? { sound: source.sound } : {}),
657
- ...(source?.haptic_style !== undefined ? { haptic_style: source.haptic_style } : {}),
658
- ...(source?.requires_ack !== undefined ? { requires_ack: source.requires_ack } : {}),
659
- ...(source?.input_type !== undefined ? { input_type: source.input_type } : {}),
660
- });
661
- }
662
- });
663
- return this.updateLayout(inviteCode, {
664
- base_action_ids: stored.map((a) => a.id).filter((id) => typeof id === 'string'),
665
- page_order: keep,
666
- actions,
667
- });
668
- }
669
- }
670
- class WebhooksApi {
671
- http;
672
- constructor(http) {
673
- this.http = http;
674
- }
675
- async list(inviteCode) {
676
- const result = await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/webhooks`);
677
- return result.webhooks;
678
- }
679
- create(inviteCode, input) {
680
- requireNonEmpty(input.name, 'name');
681
- assertActionNumber(input.action_number);
682
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/webhooks`, {
683
- body: dropUndefined({ ...input }),
684
- });
685
- }
686
- update(inviteCode, webhookId, input) {
687
- requireNonEmpty(webhookId, 'webhookId');
688
- assertActionNumber(input.action_number);
689
- return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/webhooks/${enc(webhookId)}`, {
690
- body: dropUndefined({ ...input }),
691
- });
692
- }
693
- async delete(inviteCode, webhookId) {
694
- requireNonEmpty(webhookId, 'webhookId');
695
- await this.http.request('DELETE', `/api/agent/rooms/${enc(inviteCode)}/webhooks/${enc(webhookId)}`);
696
- }
697
- }
698
- class NotificationsApi {
699
- http;
700
- constructor(http) {
701
- this.http = http;
702
- }
703
- /** Paginated list (polling fallback). Prefer wait()/listen() for real-time. */
704
- async list(input = {}) {
705
- const { notifications_per_page, ...query } = input;
706
- const page = await this.http.request('GET', '/api/agent/notifications', {
707
- query: dropUndefined({ ...query, limit: input.limit ?? notifications_per_page }),
708
- });
709
- return {
710
- ...page,
711
- data: page.data.map(({ room, ...notification }) => ({
712
- ...notification,
713
- ...(room ? { room: { ...room, code: room.invite_code } } : {}),
714
- })),
715
- };
716
- }
717
- /** Fetch one visible notification, including its current acknowledgement state. */
718
- getNotification(notificationId) {
719
- requireNonEmpty(notificationId, 'notificationId');
720
- return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}`);
721
- }
722
- /**
723
- * One bounded long-poll for an ack-required ping. The returned state may
724
- * still be `open` when the hold timeout elapses; call again to keep waiting.
725
- */
726
- waitForAcknowledgement(notificationId, input = {}) {
727
- requireNonEmpty(notificationId, 'notificationId');
728
- return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}/ack/wait`, {
729
- query: dropUndefined({ timeout: input.timeout }),
730
- timeoutMs: waitTimeoutMs(input.timeout),
731
- ...(input.signal ? { signal: input.signal } : {}),
732
- });
733
- }
734
- /** One long-poll: resolves as soon as new pings land, or with an empty list at timeout. */
735
- wait(input = {}) {
736
- return this.http.request('GET', '/api/agent/notifications/wait', {
737
- query: dropUndefined({ after: input.after, timeout: input.timeout, limit: input.limit }),
738
- timeoutMs: waitTimeoutMs(input.timeout),
739
- ...(input.signal ? { signal: input.signal } : {}),
740
- });
741
- }
742
- /**
743
- * Continuous real-time inbound: an async iterator that long-polls in a loop,
744
- * advancing the cursor, yielding each new ping. Stops when `signal` aborts.
745
- *
746
- * for await (const ping of pr.notifications.listen({ signal })) { ... }
747
- */
748
- async *listen(input = {}) {
749
- let cursor = input.after;
750
- let headRead = cursor !== undefined;
751
- const backoffMs = input.backoffMs ?? 1000;
752
- let failures = 0;
753
- while (!input.signal?.aborted) {
754
- let result;
755
- try {
756
- result = headRead
757
- ? await this.wait({
758
- after: cursor,
759
- ...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
760
- ...(input.limit !== undefined ? { limit: input.limit } : {}),
761
- ...(input.signal ? { signal: input.signal } : {}),
762
- })
763
- // No cursor yet: read the current head (an immediate, empty answer)
764
- // so only pings that land after this call are delivered.
765
- : await this.wait({ timeout: 0, ...(input.signal ? { signal: input.signal } : {}) });
766
- }
767
- catch (err) {
768
- if (input.signal?.aborted) {
769
- return;
770
- }
771
- // A revoked credential or a bad request never heals; only network
772
- // failures, deadlines, 429 and 5xx are retried.
773
- if (!isTransientError(err)) {
774
- throw err;
775
- }
776
- failures += 1;
777
- input.onError?.(err);
778
- await sleep(transientDelayMs(err, failures, backoffMs), input.signal);
779
- continue;
780
- }
781
- failures = 0;
782
- headRead = true;
783
- cursor = result.cursor ?? cursor;
784
- for (const n of result.notifications) {
785
- yield n;
786
- }
787
- // Yield between polls so timer-driven cancellation can run (see ApprovalsApi).
788
- await sleep(0, input.signal);
789
- }
790
- }
791
- }
792
- class AgentsApi {
793
- http;
794
- constructor(http) {
795
- this.http = http;
796
- }
797
- /**
798
- * @deprecated Retired server-side — always fails with 410
799
- * `cross_account_ping_retired`. An agent can only reach the account that
800
- * connected it; to reach anyone else, post into a room you both belong to.
801
- */
802
- ping(handle, ping) {
803
- requireNonEmpty(handle, 'handle');
804
- assertPing(ping);
805
- const { body, headers } = splitIdempotencyKey(ping);
806
- return this.http.request('POST', `/api/agent/agents/${enc(handle)}/ping`, {
807
- body: dropUndefined({ ...body }),
808
- ...(headers ? { headers } : {}),
809
- });
810
- }
811
- }
812
- class ApprovalsApi {
813
- http;
814
- constructor(http) {
815
- this.http = http;
816
- }
817
- /** Ask the human you act for to decide; returns the pending approval. */
818
- request(inviteCode, input) {
819
- requireNonEmpty(input.question, 'question');
820
- assertStructuredData(input.data);
821
- const { body, headers } = splitIdempotencyKey(input);
822
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/approvals`, {
823
- body: dropUndefined({ ...body }),
824
- ...(headers ? { headers } : {}),
825
- });
826
- }
827
- get(approvalId) {
828
- return this.http.request('GET', `/api/agent/approvals/${enc(approvalId)}`);
829
- }
830
- /** One long-poll for the decision; returns the (possibly still-pending) approval. */
831
- wait(approvalId, input = {}) {
832
- return this.http.request('GET', `/api/agent/approvals/${enc(approvalId)}/wait`, {
833
- query: dropUndefined({ timeout: input.timeout }),
834
- timeoutMs: waitTimeoutMs(input.timeout),
835
- ...(input.signal ? { signal: input.signal } : {}),
836
- });
837
- }
838
- /**
839
- * Block until the human decides (or the request expires or is cancelled),
840
- * looping wait() under the hood, with polls started at least 1 s apart. A
841
- * response that is not this approval in a known status rejects with
842
- * `invalid_response`. Network failures, 429 and 5xx are retried with backoff
843
- * (honouring `retryAfter`), and reported to `onRetry`, until `signal` aborts
844
- * or the outage outlasts the approval's `expires_at` by 5 minutes (15 minutes
845
- * of failures while no `expires_at` has been read); that rejects with
846
- * `unavailable`. See {@link WaitForOutcomeOptions}.
847
- */
848
- async waitForDecision(approvalId, input = {}) {
849
- requireNonEmpty(approvalId, 'approvalId');
850
- assertOnRetry(input.onRetry);
851
- return pollUntilSettled({
852
- poll: () => this.wait(approvalId, input.signal ? { signal: input.signal } : {}),
853
- validate: (value) => validateApprovalWait(value, approvalId),
854
- settled: (approval) => approval.status !== 'pending',
855
- expiresAt: (approval) => approval.expires_at,
856
- what: `approval ${approvalId}`,
857
- minIntervalMs: MIN_WAIT_POLL_INTERVAL_MS,
858
- ...(input.signal ? { signal: input.signal } : {}),
859
- ...(input.onRetry ? { onRetry: input.onRetry } : {}),
860
- });
861
- }
862
- }
863
- /**
864
- * The general human-in-the-loop primitive: ask a human a question with 2–4
865
- * options (or a short typed answer) and block until they tap an answer. An
866
- * {@link ApprovalsApi | approval} is the two-option case; reach for questions
867
- * when you need more choices, a typed reply, or room-scope answering.
868
- */
869
- class QuestionsApi {
870
- http;
871
- constructor(http) {
872
- this.http = http;
873
- }
874
- /**
875
- * Ask a Question in a room; returns the pending Question (id + computed
876
- * expiry). Pass `resolution_policy: 'all'` to poll the room: everyone asked
877
- * votes once, and `results` carries the tally on every read.
878
- */
879
- ask(inviteCode, input) {
880
- requireNonEmpty(input.prompt, 'prompt');
881
- assertStructuredData(input.data);
882
- assertPollShape(input);
883
- const { body, headers } = splitIdempotencyKey(input);
884
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/questions`, {
885
- body: dropUndefined(body),
886
- ...(headers ? { headers } : {}),
887
- });
888
- }
889
- /** Fetch a Question's current state. */
890
- get(questionId) {
891
- return this.http.request('GET', `/api/agent/questions/${enc(questionId)}`);
892
- }
893
- /** One long-poll for the answer; returns the (possibly still-pending) Question. */
894
- wait(questionId, input = {}) {
895
- return this.http.request('GET', `/api/agent/questions/${enc(questionId)}/wait`, {
896
- query: dropUndefined({ timeout: input.timeout }),
897
- timeoutMs: waitTimeoutMs(input.timeout),
898
- ...(input.signal ? { signal: input.signal } : {}),
899
- });
900
- }
901
- /**
902
- * Block until the human answers (or the question expires/cancels), looping
903
- * wait() under the hood. Returns the resolved Question — inspect `state` and
904
- * `answer.value` to branch. A poll returns once it closes; its `answer` is the
905
- * leading option, or `null` on a tie, and `results` holds the tally.
906
- *
907
- * Polls start at least 1 s apart. A response that is not this Question in a
908
- * known state rejects with `invalid_response`. Network failures, 429 and 5xx
909
- * are retried with backoff (honouring `retryAfter`), and reported to
910
- * `onRetry`, until `signal` aborts or the outage outlasts the Question's
911
- * `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
912
- * been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
913
- */
914
- async waitForAnswer(questionId, input = {}) {
915
- requireNonEmpty(questionId, 'questionId');
916
- assertOnRetry(input.onRetry);
917
- return pollUntilSettled({
918
- poll: () => this.wait(questionId, input.signal ? { signal: input.signal } : {}),
919
- validate: (value) => validateQuestionWait(value, questionId),
920
- settled: (question) => question.state !== 'pending',
921
- expiresAt: (question) => question.expires_at,
922
- what: `question ${questionId}`,
923
- minIntervalMs: MIN_WAIT_POLL_INTERVAL_MS,
924
- ...(input.signal ? { signal: input.signal } : {}),
925
- ...(input.onRetry ? { onRetry: input.onRetry } : {}),
926
- });
927
- }
928
- /** List the asker's Questions, optionally filtered by state. */
929
- async list(input = {}) {
930
- const res = await this.http.request('GET', '/api/agent/questions', {
931
- query: dropUndefined({ state: input.state }),
932
- });
933
- return res.questions ?? [];
934
- }
935
- /** Withdraw a still-pending Question (409 if it already resolved). */
936
- cancel(questionId) {
937
- return this.http.request('POST', `/api/agent/questions/${enc(questionId)}/cancel`, { body: {} });
938
- }
939
- }
940
- /**
941
- * A poll asks the whole room and counts option votes, so the server refuses it
942
- * (422 on `resolution_policy`) alongside a direct scope, a named target, or a
943
- * typed answer. Fail before the request instead.
944
- */
945
- function assertPollShape(input) {
946
- if (input.resolution_policy !== 'all')
947
- return;
948
- const target = input.target_user_id;
949
- if (input.responder_scope === 'direct'
950
- || (target !== undefined && target !== null && target !== '')
951
- || (input.text_input !== undefined && input.text_input !== null)) {
952
- throw new PingRoomError("A poll (`resolution_policy: 'all'`) asks the whole room with options only; it cannot use `responder_scope: 'direct'`, `target_user_id`, or `text_input`.", { code: 'invalid_request' });
953
- }
954
- }
955
- /** A bare string option is shorthand for `{ value, label: value }`. */
956
- function normalizeHandoffOptions(options) {
957
- return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
958
- }
959
- /** Terminal states never change again — the wait loop can stop as soon as one lands. */
960
- function isHandoffTerminal(handoff) {
961
- const s = handoff.state;
962
- return s === 'acked' || s === 'answered' || s === 'expired' || s === 'cancelled';
963
- }
964
- /**
965
- * The Handoff façade: the Agent → one-human loop. Always DIRECT (exactly one
966
- * human) and PRIVATE (no other room member can read it). Backed by the ack and
967
- * question primitives, so its {@link Handoff.state | state} space is their union
968
- * — kept DISTINCT per {@link Handoff.kind | kind}. A negative answer to a
969
- * question handoff (e.g. `hold`) is a SUCCESSFUL `answered` result, not an error.
970
- *
971
- * const task = await pr.handoffs.requestAck({ prompt, idempotencyKey });
972
- * const result = await pr.handoffs.waitForResult(task.id);
973
- *
974
- * const q = await pr.handoffs.ask({ prompt, options: ['deploy', 'hold'], idempotencyKey });
975
- */
976
- class HandoffsApi {
977
- http;
978
- constructor(http) {
979
- this.http = http;
980
- }
981
- /** Ask a human to acknowledge (an `ack` handoff). Resolves on the first acknowledgement. */
982
- requestAck(input) {
983
- requireNonEmpty(input.prompt, 'prompt');
984
- assertStructuredData(input.data);
985
- return this.create('ack', input);
986
- }
987
- /**
988
- * Ask a human a multi-option question (a `question` handoff). Resolves on the
989
- * first tapped answer. Bare-string options normalize to `{ value, label }`.
990
- */
991
- ask(input) {
992
- requireNonEmpty(input.prompt, 'prompt');
993
- assertStructuredData(input.data);
994
- if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 4) {
995
- throw new PingRoomError('`options` must contain between 2 and 4 entries for a question handoff.', {
996
- code: 'invalid_request',
997
- });
998
- }
999
- return this.create('question', input, normalizeHandoffOptions(input.options));
1000
- }
1001
- /** Fetch a handoff's current state. */
1002
- get(id) {
1003
- requireNonEmpty(id, 'id');
1004
- return this.http.request('GET', `/api/agent/handoffs/${enc(id)}`);
1005
- }
1006
- /**
1007
- * Block until the handoff resolves (or expires/cancels), looping the bounded
1008
- * long-poll under the hood. `expired`/`cancelled` and a negative answer (e.g.
1009
- * `hold`) all surface as normal terminal results — nothing is thrown for them.
1010
- *
1011
- * Polls start at least 2.1 s apart, so a short `timeout` stays under the
1012
- * route's 30-per-minute limit. A response that is not this handoff in a
1013
- * known state rejects with `invalid_response`. Network failures, 429 and 5xx
1014
- * are retried with backoff (honouring `retryAfter`), and reported to
1015
- * `onRetry`, until `signal` aborts or the outage outlasts the handoff's
1016
- * `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
1017
- * been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
1018
- */
1019
- async waitForResult(id, input = {}) {
1020
- requireNonEmpty(id, 'id');
1021
- assertOnRetry(input.onRetry);
1022
- return pollUntilSettled({
1023
- poll: () => this.wait(id, input),
1024
- validate: (value) => validateHandoffWait(value, id),
1025
- settled: isHandoffTerminal,
1026
- expiresAt: (handoff) => handoff.expires_at,
1027
- what: `handoff ${id}`,
1028
- minIntervalMs: MIN_HANDOFF_POLL_INTERVAL_MS,
1029
- ...(input.signal ? { signal: input.signal } : {}),
1030
- ...(input.onRetry ? { onRetry: input.onRetry } : {}),
1031
- });
1032
- }
1033
- /** List the agent's open handoffs or bounded recent history. */
1034
- async list(input = {}) {
1035
- const res = await this.http.request('GET', '/api/agent/handoffs', {
1036
- query: dropUndefined({ state: input.state }),
1037
- });
1038
- return res.handoffs ?? [];
1039
- }
1040
- /** One bounded long-poll; returns the (possibly still-open) handoff. */
1041
- wait(id, input) {
1042
- return this.http.request('GET', `/api/agent/handoffs/${enc(id)}/wait`, {
1043
- query: dropUndefined({ timeout: input.timeout }),
1044
- timeoutMs: waitTimeoutMs(input.timeout),
1045
- ...(input.signal ? { signal: input.signal } : {}),
1046
- });
1047
- }
1048
- create(kind, input, options) {
1049
- const body = dropUndefined({
1050
- kind,
1051
- prompt: input.prompt,
1052
- audience: { type: 'direct', user_id: input.target ?? 'me' },
1053
- expires_in: input.expiresIn,
1054
- urgency: input.urgency,
1055
- options,
1056
- correlation_id: input.correlationId,
1057
- reply_to: input.replyTo,
1058
- data: input.data,
1059
- });
1060
- return this.http.request('POST', '/api/agent/handoffs', {
1061
- body,
1062
- ...(input.idempotencyKey ? { headers: { 'Idempotency-Key': normalizeIdempotencyKey(input.idempotencyKey) } } : {}),
1063
- });
1064
- }
1065
- }
1066
- /** Setup and activation of the bound human's Agent Inbox delivery room. */
1067
- class InboxApi {
1068
- http;
1069
- constructor(http) {
1070
- this.http = http;
1071
- }
1072
- /** Replay the viable activation attempt or create its next numbered retry. */
1073
- ensure(input = {}) {
1074
- return this.http.request('POST', '/api/agent/inbox/ensure', {
1075
- body: {},
1076
- ...(input.signal ? { signal: input.signal } : {}),
1077
- });
1078
- }
1079
- /**
1080
- * Ensure the bound human's Agent Inbox, then block on its onboarding
1081
- * Question until the server confirms activation, it expires/cancels, or the
1082
- * overall deadline elapses. Pairing remains a separate operation so adopting
1083
- * a credential never silently waits for a phone response.
1084
- */
1085
- async activate(input = {}) {
1086
- const overallTimeoutMs = input.overallTimeoutMs ?? DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS;
1087
- if (!Number.isFinite(overallTimeoutMs) || overallTimeoutMs < 0) {
1088
- throw new PingRoomError('`overallTimeoutMs` must be a non-negative finite number.', {
1089
- code: 'invalid_request',
1090
- });
1091
- }
1092
- const deadlineError = () => (new PingRoomActivationIncompleteError('deadline_exceeded', 'Agent Inbox activation did not complete before the overall deadline.', { overall_timeout_ms: overallTimeoutMs }));
1093
- const deadlineController = new AbortController();
1094
- const deadlineTimer = overallTimeoutMs === 0
1095
- ? null
1096
- : setTimeout(() => deadlineController.abort(deadlineError()), overallTimeoutMs);
1097
- if (overallTimeoutMs === 0)
1098
- deadlineController.abort(deadlineError());
1099
- const signal = input.signal
1100
- ? combineSignals(input.signal, deadlineController.signal)
1101
- : deadlineController.signal;
1102
- try {
1103
- throwIfAborted(signal);
1104
- const ensured = validateInboxEnsure(await this.ensure({ signal }));
1105
- const questionId = ensured.question.id;
1106
- for (;;) {
1107
- throwIfAborted(signal);
1108
- const pollStartedAt = Date.now();
1109
- const question = validateInboxWait(await this.http.request('GET', `/api/agent/handoffs/${enc(questionId)}/wait`, {
1110
- query: dropUndefined({ timeout: input.timeout }),
1111
- timeoutMs: waitTimeoutMs(input.timeout),
1112
- signal,
1113
- }), questionId);
1114
- throwIfAborted(signal);
1115
- if (question.state === 'pending') {
1116
- await sleep(Math.max(0, MIN_HANDOFF_POLL_INTERVAL_MS - (Date.now() - pollStartedAt)), signal);
1117
- continue;
1118
- }
1119
- if (question.state === 'answered' && question.activation_completed === true) {
1120
- return {
1121
- ...ensured,
1122
- activation_completed: true,
1123
- question: question,
1124
- };
1125
- }
1126
- if (question.state === 'answered') {
1127
- throw new PingRoomActivationIncompleteError('answered_without_completion', 'The onboarding Question was answered without verified native phone receipt before the answer.', { question_id: questionId, activation_completed: question.activation_completed ?? null });
1128
- }
1129
- if (question.state === 'expired' || question.state === 'cancelled') {
1130
- throw new PingRoomActivationIncompleteError(question.state, `The onboarding Question ${question.state} before Agent Inbox activation completed.`, { question_id: questionId, activation_completed: question.activation_completed ?? null });
1131
- }
1132
- return invalidInboxResponse('PingRoom returned an invalid terminal activation state.', {
1133
- id: questionId,
1134
- state: question.state,
1135
- });
1136
- }
1137
- }
1138
- catch (error) {
1139
- // A custom fetch implementation may reject with a generic AbortError
1140
- // instead of the signal's richer reason. Preserve caller cancellation
1141
- // and the activation-specific deadline error at this public boundary.
1142
- throwIfAborted(signal);
1143
- throw error;
1144
- }
1145
- finally {
1146
- if (deadlineTimer !== null)
1147
- clearTimeout(deadlineTimer);
1148
- }
1149
- }
1150
- }
1151
- class ProfileApi {
1152
- http;
1153
- constructor(http) {
1154
- this.http = http;
1155
- }
1156
- /**
1157
- * Set this agent's own avatar (from the bot avatar set) — the robot face
1158
- * its pings and Questions wear. Never touches the bound human's profile
1159
- * picture. Returns `{ ok, avatar_id, avatar_url }`.
1160
- */
1161
- setAvatar(avatarId) {
1162
- requireNonEmpty(avatarId, 'avatarId');
1163
- return this.http.request('POST', '/api/agent/profile/avatar', { body: { avatar_id: avatarId } });
1164
- }
1165
- /** Rotate the agent's handle — the kill-switch for a leaked handle. */
1166
- rotateHandle() {
1167
- return this.http.request('POST', '/api/agent/profile/handle/rotate', { body: {} });
1168
- }
1169
- }
1170
- /**
1171
- * Live-status streams — a self-updating card on the room members' lock screen.
1172
- *
1173
- * Requires the `pingroom:live:write` scope. Free accounts get a small daily
1174
- * budget of NEW streams; updates and the terminal ping of an open stream are
1175
- * never charged, so a card can never be quota-blocked into hanging open. A
1176
- * first ping that is already `done`/`failed` is a new stream and is charged.
1177
- */
1178
- class LiveApi {
1179
- http;
1180
- constructor(http) {
1181
- this.http = http;
1182
- }
1183
- /**
1184
- * Send one stream ping — start, update, or end, chosen by `live_status.state`
1185
- * and whether the correlation already exists.
1186
- */
1187
- push(inviteCode, ping) {
1188
- requireNonEmpty(inviteCode, 'inviteCode');
1189
- requireNonEmpty(ping.correlation_id, 'correlation_id');
1190
- assertStructuredData(ping.data);
1191
- return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/live`, { body: ping });
1192
- }
1193
- /**
1194
- * Read back a stream this credential started, so a restarted producer can
1195
- * reconcile instead of opening a duplicate.
1196
- *
1197
- * Resolves null ONLY for a 404 `stream_not_found` — no stream with that
1198
- * correlation id in the last 24 hours. Every other failure throws: a 404 for
1199
- * an unknown or deleted room code, and a 403 `room_not_granted`
1200
- * ({@link RoomScopedErrorCode}) when the room is outside the grant the human
1201
- * gave this agent, both propagate rather than being reported as "no stream".
1202
- */
1203
- async get(inviteCode, correlationId) {
1204
- requireNonEmpty(inviteCode, 'inviteCode');
1205
- requireNonEmpty(correlationId, 'correlationId');
1206
- try {
1207
- return await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/live/${enc(correlationId)}`);
1208
- }
1209
- catch (error) {
1210
- if (error instanceof PingRoomError && error.status === 404 && error.code === 'stream_not_found')
1211
- return null;
1212
- throw error;
1213
- }
1214
- }
1215
- }
1216
- /**
1217
- * Private files that a Ping or Question can carry.
1218
- *
1219
- * Upload first, then pass the returned ids to `broadcast()` / `questions.ask()`
1220
- * as `attachment_ids`. Bytes never travel over MCP or a JSON ping body. An
1221
- * upload that is never attached expires on its own after 24 hours.
1222
- * Each file must be 1 byte–5 MiB with an md/pdf/html/txt/jpg/jpeg/png/zip filename;
1223
- * a Ping or Question accepts at most four ids.
1224
- *
1225
- * Uploading requires the bound account to hold Pro (the API answers 402
1226
- * `pro_required` otherwise). Reading and deleting are not gated.
1227
- */
1228
- class AttachmentsApi {
1229
- http;
1230
- constructor(http) {
1231
- this.http = http;
1232
- }
1233
- /** Upload one file and get back the id a send can claim. */
1234
- upload(input) {
1235
- requireNonEmpty(input.filename, 'filename');
1236
- // The server answers an oversized body with 413 before reading it; refuse
1237
- // locally so no bytes travel for a file that can never be accepted.
1238
- if (attachmentByteLength(input.content) > ATTACHMENT_MAX_BYTES) {
1239
- return Promise.reject(new PingRoomError('Attachments are at most 5 MiB each.', { code: 'attachment_too_large', status: 413 }));
1240
- }
1241
- const body = new FormData();
1242
- body.append('file', toAttachmentBlob(input), input.filename);
1243
- return this.http
1244
- .request('POST', '/api/agent/attachments', {
1245
- body,
1246
- ...(input.signal ? { signal: input.signal } : {}),
1247
- })
1248
- .then((res) => res.attachment);
1249
- }
1250
- /**
1251
- * Raw bytes for an attachment this credential is allowed to see. Returns the
1252
- * undecoded Response so a caller can stream it, or read text/arrayBuffer.
1253
- */
1254
- content(attachmentId, signal) {
1255
- requireNonEmpty(attachmentId, 'attachmentId');
1256
- return this.http.raw('GET', `/api/agent/attachments/${enc(attachmentId)}/content`, {
1257
- ...(signal ? { signal } : {}),
1258
- });
1259
- }
1260
- /**
1261
- * The listing of a `.zip`'s contents, recorded when it was uploaded.
1262
- *
1263
- * Resolves null for a non-archive, and for an archive whose central
1264
- * directory did not parse. Nothing decompresses the file to produce this, so
1265
- * the declared sizes are what the archive claims, not verified output.
1266
- */
1267
- manifest(attachmentId, signal) {
1268
- requireNonEmpty(attachmentId, 'attachmentId');
1269
- return this.http
1270
- .request('GET', `/api/agent/attachments/${enc(attachmentId)}/manifest`, { ...(signal ? { signal } : {}) })
1271
- .then((res) => res.manifest ?? null);
1272
- }
1273
- /** Discard an upload that has not been attached to a ping yet. */
1274
- delete(attachmentId) {
1275
- requireNonEmpty(attachmentId, 'attachmentId');
1276
- return this.http.request('DELETE', `/api/agent/attachments/${enc(attachmentId)}`);
1277
- }
1278
- }
1279
- /** Normalise the three accepted content shapes into one multipart part. */
1280
- function toAttachmentBlob(input) {
1281
- const type = input.contentType ?? '';
1282
- if (input.content instanceof Blob) {
1283
- return type ? new Blob([input.content], { type }) : input.content;
1284
- }
1285
- // Copy into a plain ArrayBuffer: a Uint8Array view may be a window onto a
1286
- // larger (or SharedArrayBuffer-backed) buffer, which Blob would not accept.
1287
- const parts = typeof input.content === 'string'
1288
- ? [input.content]
1289
- : [new Uint8Array(input.content).slice().buffer];
1290
- return new Blob(parts, type ? { type } : undefined);
1291
- }
1292
- /** Public, unauthenticated agent directory. */
1293
- class DirectoryApi {
1294
- http;
1295
- constructor(http) {
1296
- this.http = http;
1297
- }
1298
- list(input = {}) {
1299
- return this.http.request('GET', '/api/agents/directory', {
1300
- auth: false,
1301
- query: dropUndefined({ ...input }),
1302
- });
1303
- }
1304
- get(handle) {
1305
- return this.http.request('GET', `/api/agents/directory/${enc(handle)}`, { auth: false });
1306
- }
1307
- }
1308
25
  // --- main client ----------------------------------------------------------
1309
26
  export class PingRoom {
1310
27
  auth;
@@ -1313,7 +30,6 @@ export class PingRoom {
1313
30
  actions;
1314
31
  notifications;
1315
32
  attachments;
1316
- agents;
1317
33
  approvals;
1318
34
  questions;
1319
35
  handoffs;
@@ -1339,7 +55,6 @@ export class PingRoom {
1339
55
  this.actions = new ActionsApi(this.http);
1340
56
  this.notifications = new NotificationsApi(this.http);
1341
57
  this.attachments = new AttachmentsApi(this.http);
1342
- this.agents = new AgentsApi(this.http);
1343
58
  this.approvals = new ApprovalsApi(this.http);
1344
59
  this.questions = new QuestionsApi(this.http);
1345
60
  this.handoffs = new HandoffsApi(this.http);