@whooshbang/sdk 3.1.0 → 4.0.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/README.md CHANGED
@@ -1,49 +1,57 @@
1
1
  # `@whooshbang/sdk`
2
2
 
3
- Fetch-injected, ESM-only client and opt-in browser recipient surface for the
4
- release-candidate WhooshBang V1 contract. The package never retries a side
5
- effect, and has no persistence, telemetry, Node-only runtime import, or
6
- Cloudflare binding. The one thing it does retry is a read inside the composed
7
- wait — see [awaiting an answer](#awaiting-an-answer).
3
+ Use the JavaScript SDK to send messages through Telegram, Slack and email,
4
+ with an optional question. Poll delivery and answers without a public URL,
5
+ tunnel or webhook server, or receive signed webhooks. The recipient-settings
6
+ web component works in React, Vue or plain HTML.
8
7
 
9
- Install the current release candidate from the public npm registry:
8
+ The SDK is ESM and takes your `fetch` implementation. It retries safe reads
9
+ while [waiting for an answer](#awaiting-an-answer), but never retries a send
10
+ automatically.
11
+
12
+ Install the current release from the public npm registry:
10
13
 
11
14
  ```sh
12
15
  npm install @whooshbang/sdk
13
16
  ```
14
17
 
15
- For account teammate messages, `whoAmI(projectId, environmentId)` reports the
18
+ The package includes the `wb` CLI. The [agent skill](https://docs.whooshbang.com/skill.md)
19
+ explains how an agent can build WhooshBang into your app.
20
+
21
+ For organization teammate messages, `whoAmI(projectId, environmentId)` reports the
16
22
  actual principal and personal `me` identity. Use
17
- `sendOperatorMessage(projectId, environmentId, { content }, { idempotencyKey })`
23
+ `sendOperatorMessage(projectId, environmentId, { recipient: "Alex Smith", content })`
18
24
  to message the authenticated person, or add `recipient: "Alex Smith"` for a
19
25
  teammate. Omitted recipient and `me` require personal authentication; shared
20
26
  credentials return an explicit-recipient-required result and never guess their
21
- creator or the account owner. `listOperators` and `broadcastOperators` use only
27
+ creator or the organization owner. `listOperators` and `broadcastOperators` use only
22
28
  current membership and project access. Shared service email is the default;
23
29
  Notification preferences selects the person's connected channel. Marketing
24
30
  cannot use the membership-derived service binding.
25
31
 
26
32
  ```ts
27
- import { WhooshBangClient, confirmInteraction } from "@whooshbang/sdk";
33
+ import { WhooshBangClient } from "@whooshbang/sdk";
34
+
35
+ const credential = process.env.WHOOSHBANG_PROJECT_CREDENTIAL;
36
+ if (!credential) {
37
+ throw new Error("Set WHOOSHBANG_PROJECT_CREDENTIAL.");
38
+ }
28
39
 
29
40
  const whooshBang = new WhooshBangClient({
30
- baseUrl: "http://127.0.0.1:8787",
31
- credential: "synthetic-local-credential",
41
+ credential,
32
42
  fetch,
33
43
  });
34
44
 
35
45
  const sent = await whooshBang.createMessage(
36
46
  {
37
- to: { subscriber_id: "customer_123" },
38
- content: { type: "text", text: "A deployment is waiting." },
39
- interaction: confirmInteraction({
47
+ to: "customer_123",
48
+ interaction: {
40
49
  prompt: "Approve the synthetic deployment?",
41
- confirmLabel: "Approve",
42
- denyLabel: "Deny",
43
- expiresInSeconds: 900,
44
- }),
50
+ confirm_label: "Approve",
51
+ deny_label: "Deny",
52
+ expires_in_seconds: 900,
53
+ },
45
54
  },
46
- { idempotencyKey: "deploy-approval-987", timeoutMs: 10_000 },
47
55
  );
48
56
  switch (sent.outcome) {
49
57
  case "accepted":
@@ -60,7 +68,20 @@ switch (sent.outcome) {
60
68
  }
61
69
  ```
62
70
 
63
- ## Interaction builders
71
+ The client defaults to the WhooshBang production API. Supply a project credential
72
+ for the intended environment; set `baseUrl` to use another API origin. Keep it on your server, outside source control. Connect
73
+ the subscriber first using the [quickstart](../../docs/onboarding/quickstart.mdx).
74
+ For a local test without a recipient, use the
75
+ [contract mock](../../apps/contract-mock/README.md) and its synthetic credentials.
76
+
77
+ ## Optional questions
78
+
79
+ <span id="interaction-builders"></span>
80
+
81
+ Use `confirmInteraction` for Yes or no, `selectInteraction` for Pick one
82
+ (two to six choices), or `inputInteraction` for Type a reply (up to 4,000
83
+ characters). Answers don't change. Your app or agent gets the first one and later taps
84
+ are ignored, so it won't flip after you act.
64
85
 
65
86
  `confirmInteraction`, `selectInteraction`, and `inputInteraction` produce
66
87
  exactly the C0 tagged interaction union and enforce every documented bound —
@@ -82,33 +103,33 @@ clock-skew grace is applied and values are never clamped. The only upper bound
82
103
  is a representable timestamp through 9999-12-31T23:59:59.999Z.
83
104
 
84
105
  Existing absolute requests and already-created interactions keep their deadlines.
85
- An idempotent retry preserves the accepted deadline. Replay records last at
86
- least seven days and through a longer answer window. REST, CLI and MCP use the
106
+ An idempotent retry preserves the accepted deadline. Retries with the same key
107
+ are recognized for at least seven days, and longer when the answer window is longer. REST, CLI and MCP use the
87
108
  same contract (`expires_in_seconds` / `expires_at` on the interaction).
88
109
 
89
110
  ```ts
90
111
  const question = confirmInteraction({ prompt: "Approve this week's plan?", expiresInSeconds: 604800 });
91
112
  ```
92
113
 
93
- The message's outer `expires_at` is its separate delivery deadline (up to
94
- 24 hours), not the answer deadline. Delivery acceptance does not end answerability.
114
+ The message's outer `expires_at` is its separate delivery deadline (default 24 hours, extendable through year 9999), not the answer deadline. Delivery acceptance does not end answerability.
95
115
  Notification history can lose its retained body while an open question keeps
96
- its protected prompt, options and response capability until resolution.
116
+ the prompt and options needed to answer until the question ends.
117
+
118
+ ## Poll for delivery and answers
97
119
 
98
- ## Bounded response inspection
120
+ <span id="bounded-response-inspection"></span>
99
121
 
100
- `getMessage` and `getInteraction` accept `wait`, the server-held long poll in
101
- whole seconds from 0 through 30. Omitting it uses the documented server default
102
- of 30. A wait that expires is a successful current-state read, not an error, so
103
- a listener-less consumer simply reads again. Inspection never acknowledges,
104
- consumes, retries, or replays anything.
122
+ `getMessage` polls delivery; `getInteraction` polls an answer. Each request
123
+ waits up to 30 seconds for news, so there's no tight loop. Your app can run
124
+ without a public URL or webhook server. `wait` accepts whole seconds from 0
125
+ through 30 and defaults to 30. Use 0 for an immediate read. Reading doesn't
126
+ remove an answer or acknowledge an event.
105
127
 
106
128
  `interactionAnswer` reads a summary as a tagged outcome the caller branches
107
129
  on, never as a value that might be missing. `outcome` is the interaction's own
108
130
  state — `open`, `expired`, `cancelled`, `retired` — except that `answered`
109
- splits in two: `answered` carries the typed answer as the same union the
110
- machine stream and signed customer events use, and `answer_not_retained` says
111
- the person answered but the typed answer has left its retention window —
131
+ splits in two: `answered` carries the answer in `answer.type` and `answer.value`, and `answer_not_retained` says
132
+ the person answered but the answer is no longer in message history —
112
133
  `answered` and `answered_at` survive that deletion, the answer does not. A
113
134
  summary whose `answer` disagrees with its declared `type`, or whose
114
135
  `answer_retained` contradicts the presence of `answer`, is a
@@ -123,10 +144,8 @@ state, or the link's Problem Details, and never claims provider-side deletion.
123
144
 
124
145
  ## Awaiting an answer
125
146
 
126
- One server hold caps at 30 seconds; an interaction stays open until its
127
- sender-selected deadline.
128
- hours, so waiting on a person means reading again while the interaction is
129
- `open`. `awaitInteraction` is that loop, shipped once:
147
+ `awaitInteraction` repeats the polling until the person answers or the
148
+ question ends. Set `patienceMs` to stop waiting sooner:
130
149
 
131
150
  ```ts
132
151
  const read = await whooshBang.awaitInteraction(interactionId, {
@@ -152,8 +171,7 @@ It resolves with the same tagged outcome `interactionAnswer` produces, with
152
171
  one reading added: `open` means the patience bound elapsed first. `patienceMs`
153
172
  (0 through 86400000) bounds the whole wait and is deliberately not
154
173
  `timeoutMs`, which bounds one HTTP call and cannot express a fifteen-minute
155
- wait; each read inside the wait derives its own deadline from `wait` (the
156
- per-hold seconds, default 30) plus a margin, so a stalled connection fails one
174
+ wait; each read inside the wait derives its own deadline from `wait` (default 30 seconds) plus a margin, so a stalled connection fails one
157
175
  read rather than the wait. Without `patienceMs` the wait ends when the
158
176
  interaction does. Nothing ordinary throws: nobody answering in time is a
159
177
  result of asking a person a question, not a failure. Your own `signal` firing
@@ -165,14 +183,13 @@ your decision, not the wait's outcome.
165
183
  ```ts
166
184
  const asked = await whooshBang.ask(
167
185
  {
168
- to: { subscriber_id: "customer_123" },
169
- content: { type: "text", text: "A deployment is waiting." },
170
- interaction: confirmInteraction({
186
+ to: "customer_123",
187
+ interaction: {
171
188
  prompt: "Approve the synthetic deployment?",
172
- expiresInSeconds: 900,
173
- }),
189
+ expires_in_seconds: 900,
190
+ },
174
191
  },
175
- { idempotencyKey: "deploy-approval-987", patienceMs: 15 * 60_000 },
192
+ { patienceMs: 15 * 60_000 },
176
193
  );
177
194
  if (asked.outcome === "answered") {
178
195
  console.log(asked.message.id, asked.answer.value);
@@ -195,7 +212,7 @@ retries; a successful read resets the count, and the sixth consecutive failure
195
212
  ends the wait by throwing that error. A non-retryable problem, or any other
196
213
  protocol or contract failure, ends the wait by throwing at once. A read that
197
214
  comes back `open` in under a second is followed by the rest of that second, so
198
- a server that does not hold — `wait: 0`, or long polling switched off — is
215
+ a server that returns immediately — `wait: 0`, or long polling switched off — is
199
216
  polled once a second, never in a tight loop. `patienceMs` is when the wait
200
217
  stops issuing reads: a read already in flight at the bound still runs to its
201
218
  own deadline. `getInteraction` and `getMessage` called directly still make
@@ -215,7 +232,7 @@ is revealed exactly once, by create and by rotate; an equivalent idempotent
215
232
  replay returns the same logical endpoint without it.
216
233
 
217
234
  `verifyWhooshBangEvent` verifies the Standard Webhooks envelope over the exact
218
- raw request bytes and returns the typed customer event. Read the body as bytes:
235
+ raw request bytes and returns the verified customer event. Read the body as bytes:
219
236
  framework JSON re-serialization changes the signed input. Verification enforces
220
237
  the replay window, fails closed on an unknown signature version, and accepts
221
238
  both the previous and the current secret so a rotation overlap has no gap. Use
@@ -296,19 +313,17 @@ provider is `provider_outcome_unknown`, and a rate-limited one is
296
313
  ### Shared Email
297
314
 
298
315
  Use `createChannelConnection` with `provider: "email"`,
299
- `mode: "whooshbang_shared"` and a disclosed `shared_identity_id`. No provider
300
- account or DNS setup is required. This shared WhooshBang sender is intended for
301
- getting started; serious businesses should connect their own Resend team and
302
- domain for reputation, quota and billing isolation. There is no automatic
303
- migration or fallback between these senders.
316
+ `mode: "whooshbang_shared"` and a disclosed `shared_identity_id`. You don't need
317
+ a provider account or DNS setup. Email from our address adds an email charge;
318
+ see [website pricing](https://whooshbang.com/pricing).
319
+ Use your own Resend team to send from your domain. Recipients must authorize
320
+ the new sender if you switch.
304
321
 
305
322
  `createSubscriptionLinkInEnvironment` accepts an optional write-only `address`
306
323
  for an invitation, or returns a page where the recipient enters their address.
307
324
  A link visit alone cannot verify consent. Read `email_verification` and the
308
325
  masked `binding` from `getSubscriptionLinkInEnvironment`, then send through
309
326
  that exact connection and read the authoritative `message.delivery` fields.
310
- Test environments use `delivery_target: "simulator"` with
311
- `to: { subscriber_id: "test:accepted" }`; they never send email.
312
327
 
313
328
  `getSubscriberChannelSettings`, `transitionSubscriberBinding` and
314
329
  `setSubscriberPreferredBinding` manage existing bindings in one exact project
@@ -332,7 +347,7 @@ asset authorized by its hosted-session capability, verifies the PNG and
332
347
  `nosniff` response headers, and bounds the bytes before returning a `Blob`.
333
348
 
334
349
  Recipient-copy inventory, update, and reset methods use the same closed
335
- owner-voiced key enum as the service. Effective embedded-panel overrides arrive
350
+ customer-editable key enum as the service. Effective embedded-panel overrides arrive
336
351
  through the session's sparse `panel_copy`; mandatory system-owned strings are
337
352
  not writable SDK inputs.
338
353
 
@@ -341,27 +356,11 @@ import `@whooshbang/sdk/recipient/element` to register
341
356
  `<whooshbang-recipient-settings>`, or import `mountRecipientSettings` from
342
357
  `@whooshbang/sdk/recipient` for an imperative mount. These subpaths are kept out
343
358
  of the package root, so a server or Worker importing `@whooshbang/sdk` never
344
- evaluates DOM globals. These subpaths are published in
345
- `@whooshbang/sdk@1.0.0-rc.27`; `1.0.0-rc.26` does not contain them.
346
-
347
- `@whooshbang/sdk@1.0.0-rc.28` adds the accessible WhooshBang attribution and
348
- canonical red fallback while leaving every configured customer name, logo, and
349
- accent authoritative.
350
-
351
- `@whooshbang/sdk@1.0.0-rc.34` carries contracts `1.0.0-rc.25`: the environment
352
- recipient policy lists `connectable_channels`, the channels a recipient can
353
- authorize on right now.
354
-
355
- `@whooshbang/sdk@1.0.0-rc.33` carries contracts `1.0.0-rc.24`, which adds the
356
- `internal_error` problem: a retryable 500 for a fault inside WhooshBang, never
357
- a provider or a system of yours.
358
-
359
- `@whooshbang/sdk@1.0.0-rc.32` carries contracts `1.0.0-rc.23`: a project
360
- credential holding the opt-in `projects:read` / `projects:write` scopes can now
361
- read and write its own project's recipient configuration, an origin may be
362
- written in any spelling that means the same origin, and
363
- `WhooshBangProblemError.pendingInvitation` names the rollout gate an uninvited
364
- organization is waiting on.
359
+ evaluates DOM globals. The panel respects your configured name, logo and
360
+ accent. Its `connectable_channels` policy lists the channels the recipient can
361
+ authorize now. Read and write project recipient configuration using
362
+ `projects:read` and `projects:write`; a project credential can configure only
363
+ its own project.
365
364
 
366
365
  ## Machine clients
367
366
 
@@ -373,8 +372,9 @@ secret. `registerMachineCredential`, `revokeMachineCredential`,
373
372
  complete the stream lifecycle. Rotate by registering the replacement first and
374
373
  revoking the original second.
375
374
 
376
- The machine stream is proved against the deterministic C0-05 contract mock.
377
- The production machine-stream service is a separate story.
375
+ The [integration reference](../../docs/onboarding/reference.mdx#machine-events)
376
+ explains polling, durable consumption and acknowledgements. Contract-mock
377
+ scenarios exercise replay and contiguous cursor advancement locally.
378
378
 
379
379
  ## Outcomes and exceptions
380
380
 
@@ -397,18 +397,19 @@ every call site rather than a silent fallthrough.
397
397
 
398
398
  | Method | `outcome` | Carries | Means |
399
399
  | --------------------------------------------- | ----------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
400
- | `interactionAnswer` | `answered` | `answer`, `summary` | A person answered and the typed answer is retained. |
401
- | | `answer_not_retained` | `summary` | Answered; the typed answer has left its retention window. `answered_at` survives. |
400
+ | `interactionAnswer` | `answered` | `answer`, `summary` | A person answered and the answer is retained. |
401
+ | | `answer_not_retained` | `summary` | Answered; the answer is no longer in message history. `answered_at` survives. |
402
402
  | | `open` | `summary` | Nobody has answered yet. |
403
403
  | | `expired` | `summary` | The interaction reached its server-side expiry unanswered. |
404
404
  | | `cancelled` | `summary` | Cancelled before an answer. |
405
- | | `retired` | `summary` | Superseded under the first-terminal-writer rule. |
405
+ | | `retired` | `summary` | The question was retired; later answers are ignored. |
406
406
  | `createMessage`, `createMessageInEnvironment` | `accepted` | `message` | The service accepted the send; `message` is its current state. |
407
407
  | | `subscriber_unbound` | `problem` | No active binding reaches the subscriber on this notifier. Nothing was sent; usually they have to subscribe again, sometimes your routing changed. |
408
408
  | | `preferred_binding_unavailable` | `problem` | The channel the subscriber chose as preferred is paused or revoked, and the service never picks a fallback. Nothing was sent; they have to choose again or subscribe again. |
409
+ | | `funding_required` | `reason`, `problem` | Add credit or wait for a paid allowance to renew, then retry the refused request. |
409
410
  | `awaitInteraction` | as `interactionAnswer` | `summary`, `answer` on `answered` | The same members; `open` means the patience bound elapsed and the interaction can be awaited again. |
410
411
  | `ask` | as `awaitInteraction` | plus `message` | Every interaction member also carries the accepted message. |
411
- | | `subscriber_unbound`, `preferred_binding_unavailable` | `problem` | The send's own refusals, exactly as `createMessage` returns them. Nothing was sent, so there is no message. |
412
+ | | `subscriber_unbound`, `preferred_binding_unavailable`, `funding_required` | `problem` | The send's own refusals, exactly as `createMessage` returns them. Nothing was sent, so there is no message. |
412
413
  | `cancelMessage` | `cancelled` | `message` | Cancellation took effect before a worker owned the provider call. |
413
414
  | | `cancellation_too_late` | `message` | A worker already owned the provider call. `message` is the current state; no provider-side deletion is claimed. |
414
415
  | `cancelSubscriptionLink` | `cancelled` | `link` | The pending link is cancelled and can no longer create a binding. |
@@ -435,9 +436,9 @@ code throws from that same method. The one conditional classification is
435
436
  `scope_forbidden`: its explicit `billing_wallet_insufficient` or
436
437
  `billing_email_credit_insufficient` field error is an ordinary funding ceiling.
437
438
  `createMessage`, `testConnection` and `ask` return `funding_required` with the
438
- reason and problem; other scope refusals still throw. Fund the wallet or wait
439
+ reason and problem; other scope refusals still throw. Add credit or wait
439
440
  for a paid allowance to renew, then explicitly retry the same refused request.
440
- Bonus credit funds core sends only; shared email needs purchased or starter
441
+ Bonus credit funds core sends only; shared email needs purchased or welcome
441
442
  credit. These ceilings do not schedule an automatic SDK retry.
442
443
 
443
444
  | Code | Side | Why |
@@ -447,7 +448,7 @@ credit. These ceilings do not schedule an automatic SDK retry.
447
448
  | `credential_invalid` | Exception | The credential is malformed, revoked, or past its rotation overlap. Only a replacement credential fixes it. |
448
449
  | `scope_forbidden` | Exception | The credential lacks a scope, or the organization is not yet invited (`pendingInvitation`). No branch at the call site grants either. |
449
450
  | `environment_mismatch` | Exception | A credential reached across the test/live boundary. |
450
- | `idempotency_key_required` | Exception | The client sends a key on every side effect, so receiving this means the request the service saw is not the one the client sent. |
451
+ | `idempotency_key_required` | Exception | A supplied key was invalid. Omit it or use 8 to 255 visible ASCII characters. |
451
452
  | `idempotency_conflict` | Exception | Same key, different body — including the stale row-version, claimed-identity, revoked-client, and acknowledgement cursor or disposition refusals that reuse the code. The fix is a new request, never a retry. |
452
453
  | `project_slug_conflict` | Exception | The slug is already taken in this organization. Refused input, handled as `request_invalid` is: choose another. |
453
454
  | `request_invalid` | Exception | The request broke a documented bound. `fieldErrors` names each field. |
@@ -548,39 +549,26 @@ node packages/sdk-js/examples/send-interactive-message.mjs
548
549
 
549
550
  ## Compatibility
550
551
 
551
- The organization rename in SDK `1.0.0-rc.32` is a clean cut. Update old
552
- `getAccount` and credential lifecycle calls to their organization equivalents,
553
- response fields to `organization_id`, and CLI tenant options to `--organization`.
554
- An older SDK can reject a response before application code reads it, including
555
- project-scoped calls whose caller ignores the tenant field. Use the matching
556
- contracts and mock release and follow the
557
- [upgrade guide](../../docs/onboarding/organization-upgrade.mdx).
558
-
559
- The exact runtime dependency is
560
- `@whooshbang/contracts@1.0.0-rc.23`. Successful payloads are
552
+ The exact runtime dependency is pinned in `package.json`. Successful payloads are
561
553
  validated with its packaged validators, while additive response fields remain
562
554
  available on the returned object.
563
555
 
564
556
  ## Assert email consent already obtained
565
557
 
566
558
  `assertEmailConsent` activates one customer-owned email binding and creates the
567
- subscriber if needed. It requires `subscriptions:write`, the exact active email
568
- connection and domain, healthy customer OAuth and webhook authorization, and
569
- consent your application already obtained. No verification message is sent.
559
+ subscriber if needed. It requires `subscriptions:write`, healthy customer OAuth
560
+ and webhook authorization, and consent your application already obtained. The
561
+ sole customer-owned email connection supplies its domain; specify `connection_id`
562
+ when more than one exists. No verification message is sent.
570
563
 
571
564
  ```ts
572
565
  const binding = await client.assertEmailConsent(
573
566
  {
574
567
  subscriber_id: "customer_123",
575
- notifier_id: "default",
576
- connection_id: "connection_customer_email",
577
- domain_id: "emaildom_customer",
578
568
  address: "Case.Sensitive+tag@example.com",
579
569
  consented: true,
580
570
  consented_at: "2026-09-10T12:00:00Z",
581
- source: "checkout",
582
571
  },
583
- { idempotencyKey: "consent-customer-123-v1" },
584
572
  );
585
573
  ```
586
574
 
@@ -608,7 +596,7 @@ session. Linkless assertions emit `subscription.asserted` and
608
596
  `subscription.asserted_revoked`, whose `consent_assertion_id` is the binding ID.
609
597
  Existing link-backed subscription events retain their required link reference.
610
598
 
611
- For an application-verified user receiving account notices, `assertEmailConsent`
599
+ For a user whose mailbox your app has verified receiving account notices, `assertEmailConsent`
612
600
  also accepts `basis: "service"`, required `source` and `relationship_started_at`
613
601
  instead of `consented`/`consented_at`. The returned email binding has `basis:
614
602
  "service"` and `service` evidence, with no consent claim. The environment-scoped
@@ -625,3 +613,24 @@ make another consent assertion or replace a withdrawn subscriber.
625
613
  ### Migrating to SDK 3
626
614
 
627
615
  Interaction builders now return send-time inputs. Relative durations start when the server accepts the send; the builder does not calculate an absolute deadline locally. Read the effective `expires_at` from the accepted interaction response instead of the builder result. Existing explicit absolute deadlines remain supported.
616
+
617
+ ## Short inputs and retry keys
618
+
619
+ The production API URL is the default. Override `baseUrl` for a local mock or proxy.
620
+
621
+ ```ts
622
+ const wb = new WhooshBangClient({ credential: process.env.WHOOSHBANG_CREDENTIAL! });
623
+ await wb.createMessage({ to: "customer_123", content: "Order 4821 has shipped." });
624
+ await wb.ask({ to: "customer_123", interaction: { prompt: "Ship it?" } }, { patienceMs: 60_000 });
625
+ await wb.ask({ to: "customer_123", interaction: { prompt: "Choose", options: ["A", "B"] } });
626
+ await wb.ask({ to: "customer_123", interaction: { type: "input", prompt: "What should change?" } });
627
+ ```
628
+
629
+ `{ text }` and `{ blocks }` infer their content type. An image needs only its
630
+ `asset_id`; its purpose defaults to `illustration`. A string choice, or a choice
631
+ with only `label`, uses that label as its value. Prompt-only questions display once.
632
+
633
+ Every call generates its own idempotency key when omitted. If you retry an
634
+ application job, pass `{ idempotencyKey: job.id }` on every call for that job.
635
+ The same key and body replay the original result; a keyless REST retry or another
636
+ SDK call creates a new operation. Supplied keys are validated before transport.
@@ -27,6 +27,31 @@ function privacyMode(parsed) {
27
27
  }
28
28
  return value;
29
29
  }
30
+ function describeEndpoint(endpoint) {
31
+ const reason = endpoint.pause_reason === "sustained_failure"
32
+ ? "Paused after 24 hours without a successful delivery."
33
+ : endpoint.pause_reason === "probe_failed"
34
+ ? "The resume test failed; the endpoint remains paused."
35
+ : endpoint.pause_reason === "customer"
36
+ ? "Paused by a project administrator."
37
+ : undefined;
38
+ return [
39
+ `${endpoint.id} ${endpoint.status}`,
40
+ ...(reason ? [reason] : []),
41
+ ...(endpoint.recovery_pending
42
+ ? ["Delivery is held until one probe succeeds."]
43
+ : []),
44
+ ...(endpoint.next_probe_at
45
+ ? [`Next probe: ${endpoint.next_probe_at}`]
46
+ : []),
47
+ ...(endpoint.status === "paused"
48
+ ? [
49
+ `Resume: wb endpoint resume ${endpoint.id}`,
50
+ "Held events expire seven days after creation.",
51
+ ]
52
+ : []),
53
+ ];
54
+ }
30
55
  function idempotency(parsed) {
31
56
  return { idempotencyKey: parsed.options.get("key") ?? randomUUID() };
32
57
  }
@@ -1233,6 +1258,33 @@ const commands = {
1233
1258
  : await untilAnswered(api, scope, interactionId, wait);
1234
1259
  emit(context.writer, summary, describeInteraction(summary, where));
1235
1260
  },
1261
+ async "endpoint list"(context) {
1262
+ const { api, flat, projectId, environmentId } = await messageScope(context);
1263
+ const result = flat
1264
+ ? await api.listCustomerEndpointsWithCredential()
1265
+ : await api.listCustomerEndpoints(projectId, environmentId);
1266
+ emit(context.writer, result, result.items.flatMap(describeEndpoint));
1267
+ },
1268
+ async "endpoint get"(context) {
1269
+ const id = context.parsed.words[2];
1270
+ if (!id)
1271
+ throw new UsageError("wb endpoint get <endpoint-id>");
1272
+ const { api, flat, projectId, environmentId } = await messageScope(context);
1273
+ const endpoint = flat
1274
+ ? await api.getCustomerEndpointWithCredential(id)
1275
+ : await api.getCustomerEndpoint(projectId, environmentId, id);
1276
+ emit(context.writer, endpoint, describeEndpoint(endpoint));
1277
+ },
1278
+ async "endpoint resume"(context) {
1279
+ const id = context.parsed.words[2];
1280
+ if (!id)
1281
+ throw new UsageError("wb endpoint resume <endpoint-id>");
1282
+ const { api, flat, projectId, environmentId } = await messageScope(context);
1283
+ const endpoint = flat
1284
+ ? await api.resumeCustomerEndpointWithCredential(id)
1285
+ : await api.resumeCustomerEndpoint(projectId, environmentId, id);
1286
+ emit(context.writer, endpoint, describeEndpoint(endpoint));
1287
+ },
1236
1288
  async "message get"(context) {
1237
1289
  const messageId = context.parsed.words[2];
1238
1290
  if (!messageId)
@@ -1269,6 +1321,8 @@ export function strayWords(words) {
1269
1321
  // These commands each take one positional after their two-word name.
1270
1322
  const takesPositional = two === "project create" ||
1271
1323
  two === "message get" ||
1324
+ two === "endpoint get" ||
1325
+ two === "endpoint resume" ||
1272
1326
  two === "interaction get" ||
1273
1327
  two === "subscriber create" ||
1274
1328
  two === "subscriber export" ||