@pingroom/sdk 0.2.0 → 0.4.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.
package/dist/client.js CHANGED
@@ -1,11 +1,19 @@
1
+ import { PingRoomActivationIncompleteError, PingRoomError } from './errors.js';
1
2
  import { HttpClient } from './http.js';
2
- import { sleep } from './internal/async.js';
3
+ import { combineSignals, sleep } from './internal/async.js';
3
4
  import { assertActionNumber, assertStructuredData, requireNonEmpty } from './internal/guards.js';
4
5
  import { McpClient } from './mcp.js';
5
6
  const DEFAULT_BASE_URL = 'https://api.pingroom.io';
7
+ const DEFAULT_PAIRING_SCOPES = [
8
+ 'pingroom:rooms:read',
9
+ 'pingroom:broadcast:send',
10
+ 'pingroom:handoffs:create',
11
+ ];
6
12
  /** Extra wall-clock allowance over the server's hold window for long-poll calls. */
7
13
  const LONG_POLL_BUFFER_MS = 10_000;
8
14
  const DEFAULT_WAIT_SECONDS = 20;
15
+ const DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS = 120_000;
16
+ const MIN_INBOX_ACTIVATION_POLL_INTERVAL_MS = 2100;
9
17
  function envBaseUrl() {
10
18
  const proc = globalThis.process;
11
19
  return proc?.env?.['PINGROOM_API_URL'] || proc?.env?.['PINGROOM_BASE_URL'] || undefined;
@@ -30,6 +38,101 @@ function assertPing(ping) {
30
38
  function waitTimeoutMs(seconds) {
31
39
  return (seconds ?? DEFAULT_WAIT_SECONDS) * 1000 + LONG_POLL_BUFFER_MS;
32
40
  }
41
+ function isRecord(value) {
42
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
43
+ }
44
+ function isNonEmptyString(value) {
45
+ return typeof value === 'string' && value.trim() !== '';
46
+ }
47
+ function isNullableString(value) {
48
+ return value === null || typeof value === 'string';
49
+ }
50
+ function invalidInboxResponse(message, body = {}) {
51
+ throw new PingRoomError(message, { code: 'inbox_invalid_response', body });
52
+ }
53
+ function validateInboxEnsure(value) {
54
+ if (!isRecord(value) || value['onboarded'] !== true || typeof value['replayed'] !== 'boolean') {
55
+ return invalidInboxResponse('PingRoom returned an invalid Agent Inbox ensure response.');
56
+ }
57
+ const room = value['room'];
58
+ const question = value['question'];
59
+ if (!isRecord(room)
60
+ || !isNonEmptyString(room['id'])
61
+ || typeof room['name'] !== 'string'
62
+ || !isNonEmptyString(room['invite_code'])
63
+ || typeof room['is_agent_inbox'] !== 'boolean'
64
+ || !isRecord(question)
65
+ || !isNonEmptyString(question['id'])
66
+ || question['kind'] !== 'question'
67
+ || !isNonEmptyString(question['prompt'])
68
+ || !Array.isArray(question['options'])
69
+ || question['options'].some((option) => (!isRecord(option)
70
+ || !isNonEmptyString(option['value'])
71
+ || !isNonEmptyString(option['label'])))
72
+ || (question['state'] !== 'pending'
73
+ && question['state'] !== 'answered'
74
+ && question['state'] !== 'expired'
75
+ && question['state'] !== 'cancelled')
76
+ || !isNullableString(question['expires_at'])
77
+ || !isNullableString(question['created_at'])) {
78
+ return invalidInboxResponse('PingRoom returned an incomplete Agent Inbox ensure response.');
79
+ }
80
+ return value;
81
+ }
82
+ function validateInboxWait(value, expectedId) {
83
+ if (!isRecord(value)) {
84
+ return invalidInboxResponse('PingRoom returned an invalid Agent Inbox wait response.');
85
+ }
86
+ const state = value['state'];
87
+ if (!isNonEmptyString(value['id'])
88
+ || value['id'] !== expectedId
89
+ || value['kind'] !== 'question'
90
+ || (state !== 'pending' && state !== 'answered' && state !== 'expired' && state !== 'cancelled')
91
+ || (value['activation_completed'] !== undefined && typeof value['activation_completed'] !== 'boolean')
92
+ || (state !== 'answered' && value['activation_completed'] === true)) {
93
+ return invalidInboxResponse('PingRoom returned a mismatched Agent Inbox wait response.', {
94
+ expected_id: expectedId,
95
+ received_id: typeof value['id'] === 'string' ? value['id'] : null,
96
+ kind: typeof value['kind'] === 'string' ? value['kind'] : null,
97
+ state: typeof state === 'string' ? state : null,
98
+ });
99
+ }
100
+ if (state === 'answered') {
101
+ const answer = value['answer'];
102
+ if (!isRecord(answer)
103
+ || !isNullableString(answer['value'])
104
+ || !isNullableString(answer['label'])
105
+ || !isNullableString(answer['text'])
106
+ || (!isNonEmptyString(answer['value']) && !isNonEmptyString(answer['text']))
107
+ || !isNullableString(answer['answered_at'])
108
+ || (answer['responder'] !== null && !isRecord(answer['responder']))) {
109
+ return invalidInboxResponse('PingRoom returned an answered activation without a valid answer.', {
110
+ id: expectedId,
111
+ state,
112
+ });
113
+ }
114
+ const responder = answer['responder'];
115
+ if (isRecord(responder)
116
+ && (!isNullableString(responder['id']) || !isNullableString(responder['display_name']))) {
117
+ return invalidInboxResponse('PingRoom returned an invalid activation responder.', {
118
+ id: expectedId,
119
+ state,
120
+ });
121
+ }
122
+ }
123
+ else if (value['answer'] !== undefined && value['answer'] !== null) {
124
+ return invalidInboxResponse('PingRoom returned an answer for an unresolved activation.', {
125
+ id: expectedId,
126
+ state,
127
+ });
128
+ }
129
+ return value;
130
+ }
131
+ function throwIfAborted(signal) {
132
+ if (signal.aborted) {
133
+ throw signal.reason ?? new Error('aborted');
134
+ }
135
+ }
33
136
  // --- namespaces -----------------------------------------------------------
34
137
  /** Agent credential lifecycle (auth.md flow). */
35
138
  class AuthApi {
@@ -53,6 +156,107 @@ class AuthApi {
53
156
  requireNonEmpty(params.otp, 'otp');
54
157
  return this.http.request('POST', '/api/agent/auth/claim/complete', { body: dropUndefined({ ...params }) });
55
158
  }
159
+ /**
160
+ * Register a pending agent and create a short-lived link that opens the
161
+ * PingRoom app. The person approving it chooses the delivery room and sees
162
+ * the exact requested scopes. The pending credential stays private inside
163
+ * this client and is replaced automatically once waitForPairing() succeeds.
164
+ */
165
+ async startPairing(params = {}) {
166
+ const scopes = params.scopes ?? [...DEFAULT_PAIRING_SCOPES];
167
+ const previousToken = this.http.getToken();
168
+ const pending = await this.register({
169
+ type: 'anonymous',
170
+ scopes,
171
+ ...(params.agent_label ? { agent_label: params.agent_label } : {}),
172
+ });
173
+ this.http.setToken(pending.credential);
174
+ try {
175
+ return await this.http.request('POST', '/api/agent/auth/pair/start', {
176
+ body: { scopes },
177
+ });
178
+ }
179
+ catch (error) {
180
+ if (this.http.getToken() === pending.credential) {
181
+ this.http.setToken(previousToken);
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+ /** Read the current state of an app pairing using the pending credential. */
187
+ pairingStatus(options = {}) {
188
+ return this.http.request('GET', '/api/agent/auth/pair/status', { signal: options.signal });
189
+ }
190
+ /**
191
+ * Wait until the person approves in PingRoom, or the short-lived link
192
+ * expires. Transient network, 429, and 5xx failures are retried inside the
193
+ * server-provided lifetime. On success the client immediately starts using
194
+ * the active credential returned by the server.
195
+ */
196
+ async waitForPairing(pairing, options = {}) {
197
+ const lifetimeMs = Math.max(1, Number(pairing.expires_in) || 900) * 1000;
198
+ const requestedInterval = options.pollIntervalMs ?? pairing.poll_interval_ms;
199
+ const intervalMs = Math.min(Math.max(Number(requestedInterval) || 1500, 1000), 10_000);
200
+ const deadline = Date.now() + lifetimeMs;
201
+ let transientFailures = 0;
202
+ while (Date.now() < deadline) {
203
+ this.throwIfPairingAborted(options.signal);
204
+ try {
205
+ const status = await this.pairingStatus({ signal: options.signal });
206
+ this.throwIfPairingAborted(options.signal);
207
+ transientFailures = 0;
208
+ if (status.status === 'active') {
209
+ if (typeof status.credential !== 'string'
210
+ || status.credential.trim() === ''
211
+ || status.credential_type !== 'active'
212
+ || !Array.isArray(status.scopes)
213
+ || status.scopes.some((scope) => typeof scope !== 'string' || scope.trim() === '')
214
+ || (status.expires_in !== null
215
+ && (typeof status.expires_in !== 'number'
216
+ || !Number.isFinite(status.expires_in)
217
+ || status.expires_in < 0))
218
+ || !status.room
219
+ || typeof status.room.invite_code !== 'string'
220
+ || status.room.invite_code.trim() === '') {
221
+ throw new PingRoomError('PingRoom returned an incomplete approved pairing.', {
222
+ code: 'pairing_invalid_response',
223
+ body: { status: status.status },
224
+ });
225
+ }
226
+ this.http.setToken(status.credential);
227
+ const { status: _status, ...credential } = status;
228
+ return credential;
229
+ }
230
+ if (status.status === 'expired') {
231
+ throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
232
+ code: 'pairing_expired',
233
+ });
234
+ }
235
+ }
236
+ catch (error) {
237
+ if (error instanceof PingRoomError
238
+ && (error.code === 'network_error'
239
+ || error.code === 'timeout'
240
+ || error.status === 429
241
+ || error.status >= 500)) {
242
+ transientFailures += 1;
243
+ const backoffMs = Math.min(intervalMs * 2 ** Math.max(0, transientFailures - 3), 30_000);
244
+ await sleep(Math.max(0, Math.min(backoffMs, deadline - Date.now())), options.signal);
245
+ continue;
246
+ }
247
+ throw error;
248
+ }
249
+ await sleep(Math.max(0, Math.min(intervalMs, deadline - Date.now())), options.signal);
250
+ }
251
+ throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
252
+ code: 'pairing_expired',
253
+ });
254
+ }
255
+ throwIfPairingAborted(signal) {
256
+ if (signal?.aborted) {
257
+ throw signal.reason ?? new Error('aborted');
258
+ }
259
+ }
56
260
  /** Rotate to a fresh credential (same scopes/handle). */
57
261
  refresh() {
58
262
  return this.http.request('POST', '/api/agent/auth/refresh', { body: {} });
@@ -119,6 +323,23 @@ class NotificationsApi {
119
323
  list(input = {}) {
120
324
  return this.http.request('GET', '/api/agent/notifications', { query: dropUndefined({ ...input }) });
121
325
  }
326
+ /** Fetch one visible notification, including its current acknowledgement state. */
327
+ getNotification(notificationId) {
328
+ requireNonEmpty(notificationId, 'notificationId');
329
+ return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}`);
330
+ }
331
+ /**
332
+ * One bounded long-poll for an ack-required ping. The returned state may
333
+ * still be `open` when the hold timeout elapses; call again to keep waiting.
334
+ */
335
+ waitForAcknowledgement(notificationId, input = {}) {
336
+ requireNonEmpty(notificationId, 'notificationId');
337
+ return this.http.request('GET', `/api/agent/notifications/${enc(notificationId)}/ack/wait`, {
338
+ query: dropUndefined({ timeout: input.timeout }),
339
+ timeoutMs: waitTimeoutMs(input.timeout),
340
+ ...(input.signal ? { signal: input.signal } : {}),
341
+ });
342
+ }
122
343
  /** One long-poll: resolves as soon as new pings land, or with an empty list at timeout. */
123
344
  wait(input = {}) {
124
345
  return this.http.request('GET', '/api/agent/notifications/wait', {
@@ -176,7 +397,11 @@ class AgentsApi {
176
397
  constructor(http) {
177
398
  this.http = http;
178
399
  }
179
- /** Direct agent-to-agent ping, addressed by the target's handle. */
400
+ /**
401
+ * @deprecated Retired server-side — always fails with 410
402
+ * `cross_account_ping_retired`. An agent can only reach the account that
403
+ * connected it; to reach anyone else, post into a room you both belong to.
404
+ */
180
405
  ping(handle, ping) {
181
406
  requireNonEmpty(handle, 'handle');
182
407
  assertPing(ping);
@@ -279,6 +504,192 @@ class QuestionsApi {
279
504
  return this.http.request('POST', `/api/agent/questions/${enc(questionId)}/cancel`, { body: {} });
280
505
  }
281
506
  }
507
+ /** A bare string option is shorthand for `{ value, label: value }`. */
508
+ function normalizeHandoffOptions(options) {
509
+ return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
510
+ }
511
+ /** Terminal states never change again — the wait loop can stop as soon as one lands. */
512
+ function isHandoffTerminal(handoff) {
513
+ const s = handoff.state;
514
+ return s === 'acked' || s === 'answered' || s === 'expired' || s === 'cancelled';
515
+ }
516
+ /**
517
+ * The Handoff façade: the Agent → one-human loop. Always DIRECT (exactly one
518
+ * human) and PRIVATE (no other room member can read it). Backed by the ack and
519
+ * question primitives, so its {@link Handoff.state | state} space is their union
520
+ * — kept DISTINCT per {@link Handoff.kind | kind}. A negative answer to a
521
+ * question handoff (e.g. `hold`) is a SUCCESSFUL `answered` result, not an error.
522
+ *
523
+ * const task = await pr.handoffs.requestAck({ prompt, idempotencyKey });
524
+ * const result = await pr.handoffs.waitForResult(task.id);
525
+ *
526
+ * const q = await pr.handoffs.ask({ prompt, options: ['deploy', 'hold'], idempotencyKey });
527
+ */
528
+ class HandoffsApi {
529
+ http;
530
+ constructor(http) {
531
+ this.http = http;
532
+ }
533
+ /** Ask a human to acknowledge (an `ack` handoff). Resolves on the first acknowledgement. */
534
+ requestAck(input) {
535
+ requireNonEmpty(input.prompt, 'prompt');
536
+ assertStructuredData(input.data);
537
+ return this.create('ack', input);
538
+ }
539
+ /**
540
+ * Ask a human a multi-option question (a `question` handoff). Resolves on the
541
+ * first tapped answer. Bare-string options normalize to `{ value, label }`.
542
+ */
543
+ ask(input) {
544
+ requireNonEmpty(input.prompt, 'prompt');
545
+ assertStructuredData(input.data);
546
+ if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 4) {
547
+ throw new PingRoomError('`options` must contain between 2 and 4 entries for a question handoff.', {
548
+ code: 'invalid_request',
549
+ });
550
+ }
551
+ return this.create('question', input, normalizeHandoffOptions(input.options));
552
+ }
553
+ /** Fetch a handoff's current state. */
554
+ get(id) {
555
+ requireNonEmpty(id, 'id');
556
+ return this.http.request('GET', `/api/agent/handoffs/${enc(id)}`);
557
+ }
558
+ /**
559
+ * Block until the handoff resolves (or expires/cancels), looping the bounded
560
+ * long-poll under the hood. `expired`/`cancelled` and a negative answer (e.g.
561
+ * `hold`) all surface as normal terminal results — nothing is thrown for them.
562
+ */
563
+ async waitForResult(id, input = {}) {
564
+ requireNonEmpty(id, 'id');
565
+ for (;;) {
566
+ if (input.signal?.aborted) {
567
+ throw input.signal.reason ?? new Error('aborted');
568
+ }
569
+ const handoff = await this.wait(id, input);
570
+ if (isHandoffTerminal(handoff)) {
571
+ return handoff;
572
+ }
573
+ }
574
+ }
575
+ /** List the agent's open handoffs or bounded recent history. */
576
+ async list(input = {}) {
577
+ const res = await this.http.request('GET', '/api/agent/handoffs', {
578
+ query: dropUndefined({ state: input.state }),
579
+ });
580
+ return res.handoffs ?? [];
581
+ }
582
+ /** One bounded long-poll; returns the (possibly still-open) handoff. */
583
+ wait(id, input) {
584
+ return this.http.request('GET', `/api/agent/handoffs/${enc(id)}/wait`, {
585
+ query: dropUndefined({ timeout: input.timeout }),
586
+ timeoutMs: waitTimeoutMs(input.timeout),
587
+ ...(input.signal ? { signal: input.signal } : {}),
588
+ });
589
+ }
590
+ create(kind, input, options) {
591
+ const body = dropUndefined({
592
+ kind,
593
+ prompt: input.prompt,
594
+ audience: { type: 'direct', user_id: input.target ?? 'me' },
595
+ expires_in: input.expiresIn,
596
+ urgency: input.urgency,
597
+ options,
598
+ correlation_id: input.correlationId,
599
+ reply_to: input.replyTo,
600
+ data: input.data,
601
+ });
602
+ return this.http.request('POST', '/api/agent/handoffs', {
603
+ body,
604
+ ...(input.idempotencyKey ? { headers: { 'Idempotency-Key': input.idempotencyKey } } : {}),
605
+ });
606
+ }
607
+ }
608
+ /** Setup and activation of the bound human's Agent Inbox delivery room. */
609
+ class InboxApi {
610
+ http;
611
+ constructor(http) {
612
+ this.http = http;
613
+ }
614
+ /** Replay the viable activation attempt or create its next numbered retry. */
615
+ ensure(input = {}) {
616
+ return this.http.request('POST', '/api/agent/inbox/ensure', {
617
+ body: {},
618
+ ...(input.signal ? { signal: input.signal } : {}),
619
+ });
620
+ }
621
+ /**
622
+ * Ensure the bound human's Agent Inbox, then block on its onboarding
623
+ * Question until the server confirms activation, it expires/cancels, or the
624
+ * overall deadline elapses. Pairing remains a separate operation so adopting
625
+ * a credential never silently waits for a phone response.
626
+ */
627
+ async activate(input = {}) {
628
+ const overallTimeoutMs = input.overallTimeoutMs ?? DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS;
629
+ if (!Number.isFinite(overallTimeoutMs) || overallTimeoutMs < 0) {
630
+ throw new PingRoomError('`overallTimeoutMs` must be a non-negative finite number.', {
631
+ code: 'invalid_request',
632
+ });
633
+ }
634
+ const deadlineError = () => (new PingRoomActivationIncompleteError('deadline_exceeded', 'Agent Inbox activation did not complete before the overall deadline.', { overall_timeout_ms: overallTimeoutMs }));
635
+ const deadlineController = new AbortController();
636
+ const deadlineTimer = overallTimeoutMs === 0
637
+ ? null
638
+ : setTimeout(() => deadlineController.abort(deadlineError()), overallTimeoutMs);
639
+ if (overallTimeoutMs === 0)
640
+ deadlineController.abort(deadlineError());
641
+ const signal = input.signal
642
+ ? combineSignals(input.signal, deadlineController.signal)
643
+ : deadlineController.signal;
644
+ try {
645
+ throwIfAborted(signal);
646
+ const ensured = validateInboxEnsure(await this.ensure({ signal }));
647
+ const questionId = ensured.question.id;
648
+ for (;;) {
649
+ throwIfAborted(signal);
650
+ const pollStartedAt = Date.now();
651
+ const question = validateInboxWait(await this.http.request('GET', `/api/agent/handoffs/${enc(questionId)}/wait`, {
652
+ query: dropUndefined({ timeout: input.timeout }),
653
+ timeoutMs: waitTimeoutMs(input.timeout),
654
+ signal,
655
+ }), questionId);
656
+ throwIfAborted(signal);
657
+ if (question.state === 'pending') {
658
+ await sleep(Math.max(0, MIN_INBOX_ACTIVATION_POLL_INTERVAL_MS - (Date.now() - pollStartedAt)), signal);
659
+ continue;
660
+ }
661
+ if (question.state === 'answered' && question.activation_completed === true) {
662
+ return {
663
+ ...ensured,
664
+ activation_completed: true,
665
+ question: question,
666
+ };
667
+ }
668
+ if (question.state === 'answered') {
669
+ 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 });
670
+ }
671
+ if (question.state === 'expired' || question.state === 'cancelled') {
672
+ throw new PingRoomActivationIncompleteError(question.state, `The onboarding Question ${question.state} before Agent Inbox activation completed.`, { question_id: questionId, activation_completed: question.activation_completed ?? null });
673
+ }
674
+ return invalidInboxResponse('PingRoom returned an invalid terminal activation state.', {
675
+ id: questionId,
676
+ state: question.state,
677
+ });
678
+ }
679
+ }
680
+ catch (error) {
681
+ // A custom fetch implementation may reject with a generic AbortError
682
+ // instead of the signal's richer reason. Preserve caller cancellation
683
+ // and the activation-specific deadline error at this public boundary.
684
+ throwIfAborted(signal);
685
+ throw error;
686
+ }
687
+ finally {
688
+ if (deadlineTimer !== null)
689
+ clearTimeout(deadlineTimer);
690
+ }
691
+ }
692
+ }
282
693
  class ProfileApi {
283
694
  http;
284
695
  constructor(http) {
@@ -294,6 +705,102 @@ class ProfileApi {
294
705
  return this.http.request('POST', '/api/agent/profile/handle/rotate', { body: {} });
295
706
  }
296
707
  }
708
+ /**
709
+ * Live-status streams — a self-updating card on the room members' lock screen.
710
+ *
711
+ * Requires the `pingroom:live:write` scope. Free accounts get a small daily
712
+ * budget of NEW streams; updates and the terminal ping are never charged, so a
713
+ * card can never be quota-blocked into hanging open.
714
+ */
715
+ class LiveApi {
716
+ http;
717
+ constructor(http) {
718
+ this.http = http;
719
+ }
720
+ /**
721
+ * Send one stream ping — start, update, or end, chosen by `live_status.state`
722
+ * and whether the correlation already exists.
723
+ */
724
+ push(inviteCode, ping) {
725
+ requireNonEmpty(inviteCode, 'inviteCode');
726
+ requireNonEmpty(ping.correlation_id, 'correlation_id');
727
+ assertStructuredData(ping.data);
728
+ return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/live`, { body: ping });
729
+ }
730
+ /**
731
+ * Read back a stream this credential started, so a restarted producer can
732
+ * reconcile instead of opening a duplicate. Returns null when there is no
733
+ * such stream in the last 24 hours.
734
+ */
735
+ async get(inviteCode, correlationId) {
736
+ requireNonEmpty(inviteCode, 'inviteCode');
737
+ requireNonEmpty(correlationId, 'correlationId');
738
+ try {
739
+ return await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/live/${enc(correlationId)}`);
740
+ }
741
+ catch (error) {
742
+ if (error instanceof PingRoomError && error.status === 404)
743
+ return null;
744
+ throw error;
745
+ }
746
+ }
747
+ }
748
+ /**
749
+ * Private files that a ping can carry.
750
+ *
751
+ * Upload first, then pass the returned ids to `broadcast()` / `questions.ask()`
752
+ * as `attachment_ids`. Bytes never travel over MCP or a JSON ping body. An
753
+ * upload that is never attached expires on its own after 24 hours.
754
+ *
755
+ * Uploading requires the bound account to hold Pro (the API answers 402
756
+ * `pro_required` otherwise). Reading and deleting are not gated.
757
+ */
758
+ class AttachmentsApi {
759
+ http;
760
+ constructor(http) {
761
+ this.http = http;
762
+ }
763
+ /** Upload one file and get back the id a send can claim. */
764
+ upload(input) {
765
+ requireNonEmpty(input.filename, 'filename');
766
+ const body = new FormData();
767
+ body.append('file', toAttachmentBlob(input), input.filename);
768
+ return this.http
769
+ .request('POST', '/api/agent/attachments', {
770
+ body,
771
+ ...(input.signal ? { signal: input.signal } : {}),
772
+ })
773
+ .then((res) => res.attachment);
774
+ }
775
+ /**
776
+ * Raw bytes for an attachment this credential is allowed to see. Returns the
777
+ * undecoded Response so a caller can stream it, or read text/arrayBuffer.
778
+ */
779
+ content(attachmentId, signal) {
780
+ requireNonEmpty(attachmentId, 'attachmentId');
781
+ return this.http.raw('GET', `/api/agent/attachments/${enc(attachmentId)}/content`, {
782
+ ...(signal ? { signal } : {}),
783
+ });
784
+ }
785
+ /** Discard an upload that has not been attached to a ping yet. */
786
+ delete(attachmentId) {
787
+ requireNonEmpty(attachmentId, 'attachmentId');
788
+ return this.http.request('DELETE', `/api/agent/attachments/${enc(attachmentId)}`);
789
+ }
790
+ }
791
+ /** Normalise the three accepted content shapes into one multipart part. */
792
+ function toAttachmentBlob(input) {
793
+ const type = input.contentType ?? '';
794
+ if (input.content instanceof Blob) {
795
+ return type ? new Blob([input.content], { type }) : input.content;
796
+ }
797
+ // Copy into a plain ArrayBuffer: a Uint8Array view may be a window onto a
798
+ // larger (or SharedArrayBuffer-backed) buffer, which Blob would not accept.
799
+ const parts = typeof input.content === 'string'
800
+ ? [input.content]
801
+ : [new Uint8Array(input.content).slice().buffer];
802
+ return new Blob(parts, type ? { type } : undefined);
803
+ }
297
804
  /** Public, unauthenticated agent directory. */
298
805
  class DirectoryApi {
299
806
  http;
@@ -316,9 +823,13 @@ export class PingRoom {
316
823
  rooms;
317
824
  actions;
318
825
  notifications;
826
+ attachments;
319
827
  agents;
320
828
  approvals;
321
829
  questions;
830
+ handoffs;
831
+ inbox;
832
+ live;
322
833
  profile;
323
834
  directory;
324
835
  mcp;
@@ -337,9 +848,13 @@ export class PingRoom {
337
848
  this.rooms = new RoomsApi(this.http);
338
849
  this.actions = new ActionsApi(this.http);
339
850
  this.notifications = new NotificationsApi(this.http);
851
+ this.attachments = new AttachmentsApi(this.http);
340
852
  this.agents = new AgentsApi(this.http);
341
853
  this.approvals = new ApprovalsApi(this.http);
342
854
  this.questions = new QuestionsApi(this.http);
855
+ this.handoffs = new HandoffsApi(this.http);
856
+ this.inbox = new InboxApi(this.http);
857
+ this.live = new LiveApi(this.http);
343
858
  this.profile = new ProfileApi(this.http);
344
859
  this.directory = new DirectoryApi(this.http);
345
860
  this.mcp = new McpClient(this.http);