@msg91comm/m91 0.0.0-stage → 1.1.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 (70) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +21 -0
  3. package/README.md +425 -2
  4. package/dist/src/client.d.ts +165 -0
  5. package/dist/src/client.d.ts.map +1 -0
  6. package/dist/src/client.js +221 -0
  7. package/dist/src/client.js.map +1 -0
  8. package/dist/src/exceptions.d.ts +114 -0
  9. package/dist/src/exceptions.d.ts.map +1 -0
  10. package/dist/src/exceptions.js +199 -0
  11. package/dist/src/exceptions.js.map +1 -0
  12. package/dist/src/http.d.ts +71 -0
  13. package/dist/src/http.d.ts.map +1 -0
  14. package/dist/src/http.js +150 -0
  15. package/dist/src/http.js.map +1 -0
  16. package/dist/src/index.d.ts +49 -0
  17. package/dist/src/index.d.ts.map +1 -0
  18. package/dist/src/index.js +45 -0
  19. package/dist/src/index.js.map +1 -0
  20. package/dist/src/integrations/index.d.ts +22 -0
  21. package/dist/src/integrations/index.d.ts.map +1 -0
  22. package/dist/src/integrations/index.js +21 -0
  23. package/dist/src/integrations/index.js.map +1 -0
  24. package/dist/src/integrations/langchain.d.ts +43 -0
  25. package/dist/src/integrations/langchain.d.ts.map +1 -0
  26. package/dist/src/integrations/langchain.js +75 -0
  27. package/dist/src/integrations/langchain.js.map +1 -0
  28. package/dist/src/integrations/langgraph.d.ts +79 -0
  29. package/dist/src/integrations/langgraph.d.ts.map +1 -0
  30. package/dist/src/integrations/langgraph.js +114 -0
  31. package/dist/src/integrations/langgraph.js.map +1 -0
  32. package/dist/src/integrations/specs.d.ts +131 -0
  33. package/dist/src/integrations/specs.d.ts.map +1 -0
  34. package/dist/src/integrations/specs.js +156 -0
  35. package/dist/src/integrations/specs.js.map +1 -0
  36. package/dist/src/integrations/vercel-ai.d.ts +36 -0
  37. package/dist/src/integrations/vercel-ai.d.ts.map +1 -0
  38. package/dist/src/integrations/vercel-ai.js +67 -0
  39. package/dist/src/integrations/vercel-ai.js.map +1 -0
  40. package/dist/src/models.d.ts +274 -0
  41. package/dist/src/models.d.ts.map +1 -0
  42. package/dist/src/models.js +383 -0
  43. package/dist/src/models.js.map +1 -0
  44. package/dist/src/platform.d.ts +176 -0
  45. package/dist/src/platform.d.ts.map +1 -0
  46. package/dist/src/platform.js +286 -0
  47. package/dist/src/platform.js.map +1 -0
  48. package/dist/src/url-builder.d.ts +65 -0
  49. package/dist/src/url-builder.d.ts.map +1 -0
  50. package/dist/src/url-builder.js +167 -0
  51. package/dist/src/url-builder.js.map +1 -0
  52. package/examples/approval-gate.ts +35 -0
  53. package/examples/basic-alert.ts +53 -0
  54. package/examples/langchain-agent.ts +65 -0
  55. package/examples/langgraph-hitl.ts +70 -0
  56. package/examples/vercel-ai-agent.ts +34 -0
  57. package/examples/webhook-urls.ts +44 -0
  58. package/package.json +83 -4
  59. package/src/client.ts +324 -0
  60. package/src/exceptions.ts +229 -0
  61. package/src/http.ts +174 -0
  62. package/src/index.ts +91 -0
  63. package/src/integrations/index.ts +23 -0
  64. package/src/integrations/langchain.ts +104 -0
  65. package/src/integrations/langgraph.ts +142 -0
  66. package/src/integrations/specs.ts +224 -0
  67. package/src/integrations/vercel-ai.ts +99 -0
  68. package/src/models.ts +660 -0
  69. package/src/platform.ts +343 -0
  70. package/src/url-builder.ts +225 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0
4
+
5
+ Three gaps where the server had grown a field and the SDK had not, so the only
6
+ route to a value you had explicitly asked for was the raw body.
7
+
8
+ **Nothing breaks.** `AlertDetail.responses` holds typed objects now, and they
9
+ still answer to `r["label"]` and `r.get("note")` — the spelling callers already
10
+ use — so upgrading changes nothing you have written. (Python; in Node they were
11
+ `unknown[]`, which required a cast either way.)
12
+
13
+ ### Added
14
+
15
+ - `send({ attachments: [...] })` — https links to files the alert is about. URLs,
16
+ never bytes, because every automation a send link is pasted into has a URL
17
+ field and little else. Validated locally so a typo costs no round trip.
18
+ - `send({ callbackUrl: ... })` — where the outcome is POSTed when somebody answers.
19
+ - `Response.note` and `Response.attachments`, the two things a sender can
20
+ require before an option may be chosen.
21
+ - `AlertDetail.attachments` and `AlertDetail.pending`.
22
+ - `Attachment` and `Response` types.
23
+ - `M91Platform.listChannels()` — the
24
+ missing half of the non-idempotent `create_channel`. A platform that lost the
25
+ response to a create had an orphaned channel and no way to find it.
26
+
27
+
28
+ ## 1.0.0
29
+
30
+ First release.
31
+
32
+ - `M91Client` — verify, send, getAlert, waitForDecision, close, requestApproval,
33
+ against a channel send link.
34
+ - `M91Platform` — verify the key, invite and alert one person by phone number,
35
+ and provision team channels, with an API key.
36
+ - `M91URLBuilder` — the GET form, for anything whose only field is "URL to call".
37
+ - Integrations: LangChain, LangGraph and the Vercel AI SDK.
38
+
39
+ Zero runtime dependencies: native `fetch`, Node 18+. Every framework adapter
40
+ imports its framework lazily, so installing this package pulls in none of them.
41
+
42
+ Four behaviours worth calling out, because they are the ones that differ from
43
+ what a hand-rolled wrapper around `fetch` usually does:
44
+
45
+ - **A send that reaches nobody throws `M91DeliveryError`.** `delivered` counts
46
+ phones actually sent to, not the size of the roster, so a channel whose device
47
+ tokens are all stale answers 201 and wakes nobody. This applies to
48
+ `alertPerson` as well as `send`, and matters more there: a direct alert has a
49
+ roster of one. Pass `{ throwOnZeroDelivery: false }` to opt out.
50
+ - **`close()` treats 404 and 403 as success.** Nothing left to close is the
51
+ state the caller asked for.
52
+ - **`waitForDecision()` reads `decision` only.** A PERSONAL response ("Seen")
53
+ never ends the wait, because "Seen" must never read as "Approved".
54
+ - **`verify()` on both clients tests a credential without waking anybody.**
55
+ `M91Client.verify()` pings the send link; `M91Platform.verify()` calls
56
+ `GET /api/v1/me` and names the key and its owner.
57
+ - **The send token is redacted from every string the SDK produces** — `toString`,
58
+ error messages and tool results — including text written by the runtime, which
59
+ can name the URL a request failed on.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MSG91
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,426 @@
1
- # Temporary Holding Version
1
+ # m91
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Wake a human when software cannot carry on alone.** An M91 alert rings every
4
+ phone in a channel at full volume — through silent mode and Do Not Disturb — and
5
+ keeps ringing until somebody answers. HIGH and CRITICAL alerts place a voice
6
+ call if 45 seconds pass in silence. Whoever takes the alert on is recorded, and
7
+ it clears for everyone else.
8
+
9
+ Email, SMS and chat arrive quietly and wait to be read. That is the right
10
+ behaviour for almost everything, and the wrong behaviour when somebody has to
11
+ act now.
12
+
13
+ ```bash
14
+ npm install @msg91comm/m91
15
+ ```
16
+
17
+ Node 18+. **Zero dependencies** — native `fetch`, nothing else.
18
+
19
+ ---
20
+
21
+ ## Installation
22
+
23
+ The package itself needs nothing. The framework adapters use optional peer
24
+ dependencies, imported only when you call one:
25
+
26
+ | For | Install alongside |
27
+ |---|---|
28
+ | `@msg91comm/m91` — client, platform, URL builder | nothing |
29
+ | `@msg91comm/m91/integrations/langchain` | `@langchain/core` |
30
+ | `@msg91comm/m91/integrations/langgraph` | nothing — it wraps the client, not the graph |
31
+ | `@msg91comm/m91/integrations/vercel-ai` | `ai` |
32
+
33
+ ---
34
+
35
+ ## Quick start
36
+
37
+ You need a **send link** — `https://m91.msg91.com/s/<token>` — from the channel
38
+ in the M91 app. The token is in the URL path, so the link *is* the credential:
39
+ keep it in the environment, never in the repository.
40
+
41
+ ```ts
42
+ import { M91Client } from '@msg91comm/m91';
43
+
44
+ const client = new M91Client(process.env.M91_SEND_LINK!);
45
+
46
+ await client.verify(); // raises nothing; names the channel
47
+ await client.send('Database disk 90% full on node-7', {
48
+ severity: 'HIGH',
49
+ customId: 'disk-full-node7', // stable: the condition, not the moment
50
+ });
51
+ await client.close({ customId: 'disk-full-node7' }); // when the disk has been cleared
52
+ ```
53
+
54
+ `verify()` is worth calling on startup. A mistyped link otherwise looks
55
+ perfectly healthy until the night it has to work.
56
+
57
+ ---
58
+
59
+ ## The approval gate
60
+
61
+ The reason an agent wants this: a question that blocks until a person answers on
62
+ their phone.
63
+
64
+ ```ts
65
+ const approved = await client.requestApproval(
66
+ 'Agent wants to refund ₹48,000 on order ORD-40122',
67
+ {
68
+ description: 'Customer says it never arrived. Courier shows delivered, signed for.',
69
+ customId: 'refund-ORD-40122',
70
+ timeoutMs: 300_000,
71
+ },
72
+ );
73
+
74
+ if (approved) await processRefund();
75
+ ```
76
+
77
+ One call raises the alert, waits, and closes it afterwards. It resolves `true`
78
+ only when somebody tapped **Approve**. A rejection, a timeout and an unreachable
79
+ channel all resolve `false` or throw — there is no configuration under which
80
+ silence means yes.
81
+
82
+ ---
83
+
84
+ ## Severity
85
+
86
+ | | Delivery | Voice call |
87
+ |---|---|---|
88
+ | `LOW` | Rings through Do Not Disturb, repeats until answered | no |
89
+ | `MEDIUM` — the default | Rings through Do Not Disturb, repeats until answered | no |
90
+ | `HIGH` | The same | after 45s with no answer |
91
+ | `CRITICAL` | The same | after 45s with no answer |
92
+
93
+ MEDIUM is the default because HIGH dials phones, and "I didn't specify" must not
94
+ mean "ring everyone". `requestApproval` defaults to HIGH instead — something is
95
+ waiting on the answer, so a call after 45 seconds of silence is the point.
96
+
97
+ ---
98
+
99
+ ## Alert URLs
100
+
101
+ For anything whose only integration field is "URL to call": Grafana, Sentry,
102
+ UptimeRobot, Zapier, a GitHub Action, a cron `curl`, an iOS Shortcut, a browser
103
+ bookmark.
104
+
105
+ ```ts
106
+ import { M91URLBuilder } from '@msg91comm/m91';
107
+
108
+ const builder = new M91URLBuilder(process.env.M91_SEND_LINK!);
109
+
110
+ console.log(builder.build('CPU above 95% on prod-server-3', {
111
+ severity: 'HIGH',
112
+ customId: 'cpu-high-prod3',
113
+ respond: ['On it', 'Escalate'],
114
+ }));
115
+ // https://m91.msg91.com/s/TOKEN?title=CPU+above+95%25+on+prod-server-3&severity=HIGH
116
+ // &customId=cpu-high-prod3&respond=On+it%2CEscalate
117
+ ```
118
+
119
+ Paste that into Grafana's webhook field and it works — no client, no headers, no
120
+ code.
121
+
122
+ In `respond`, the **first label is SHARED** and the rest are PERSONAL. Up to six
123
+ in a link. To make a later one SHARED too, pass `{ label: 'Also on it' }`
124
+ instead of the bare string, or add a trailing `!`.
125
+
126
+ `builder.testUrl()` returns the bare link. Opening it **raises nothing** and
127
+ answers with the channel's name, which is also why a link preview, a crawler or
128
+ a mail scanner touching a pasted URL wakes nobody.
129
+
130
+ ---
131
+
132
+ ## Platform API — alerting one person
133
+
134
+ For a product that discovers its users at runtime, where a per-team send link
135
+ does not fit. Needs an API key (`m91sk_…`), minted from the app.
136
+
137
+ ```ts
138
+ import { M91Platform } from '@msg91comm/m91';
139
+
140
+ const platform = new M91Platform(process.env.M91_API_KEY!);
141
+
142
+ await platform.verify(); // names the key and its owner. Alerts nobody.
143
+
144
+ // Idempotent — this belongs in your login handler, not a migration script.
145
+ // The label is your PRODUCT name: it is what she sees on the alert.
146
+ const recipient = await platform.inviteRecipient('919876543210', 'Acme CRM');
147
+
148
+ if (recipient.state === 'accepted') {
149
+ await platform.alertPerson('919876543210', 'Your nightly export failed', {
150
+ customId: 'export-fail-priya-2026-09',
151
+ });
152
+ }
153
+ ```
154
+
155
+ `label` is **what the person sees as the name of the thing alerting them** — your
156
+ product, not their name. It defaults to `"Direct alerts"`, and **every invite
157
+ overwrites it**, so pass the same constant on every login rather than letting a
158
+ later call reset it.
159
+
160
+ Only an **accepted** recipient can be woken. `pending` means they have been
161
+ asked and have not answered — nag them in your own UI rather than re-inviting in
162
+ a loop. `declined` is terminal: no API re-invites a declined number, and only
163
+ they can undo it.
164
+
165
+ An API key can also *provision* a team channel — `createChannel`, `addMembers`,
166
+ `removeMember` — but it **cannot alert one**. Alerting a team is always the send
167
+ link `createChannel` returns. A leaked key can restaff a channel; it cannot ring
168
+ a single phone in it.
169
+
170
+ `alertPerson` throws `M91DeliveryError` on a 2xx that reached nobody, same as
171
+ `send`. It matters more here: a direct alert has a roster of one, so a single
172
+ stale device token is the difference between waking somebody and waking nobody.
173
+
174
+ The cases an API key exists for — per-user alerting, per-tenant channels, roster
175
+ sync — and the much larger set where a send link is the right answer, are worked
176
+ through at **<https://m91.msg91.com/docs>**.
177
+
178
+ ---
179
+
180
+ ## LangChain
181
+
182
+ ```ts
183
+ import { M91Client } from '@msg91comm/m91';
184
+ import { createM91ApprovalTool, createM91AlertTool } from '@msg91comm/m91/integrations/langchain';
185
+
186
+ const client = new M91Client(process.env.M91_SEND_LINK!);
187
+
188
+ const tools = [
189
+ await createM91ApprovalTool(client), // blocks for a human
190
+ await createM91AlertTool(client), // wakes somebody, carries on
191
+ ...myTools,
192
+ ];
193
+ const agent = createReactAgent({ llm, tools, prompt: SYSTEM });
194
+ ```
195
+
196
+ The factories are async because `@langchain/core` is imported only when you call
197
+ one — that is what keeps `npm install @msg91comm/m91` at zero dependencies, which matters
198
+ for a package whose job is to work when everything else is on fire.
199
+
200
+ The tool descriptions tell the model when to reach for each one. Your system
201
+ prompt still has to say that approval is **required** before irreversible
202
+ actions, and to list them — see `examples/langchain-agent.ts`, included with the SDK.
203
+
204
+ `createM91ApprovalTool` returns a tool that answers `"approved"`, `"rejected"`
205
+ or `"error: …"`. Nothing but a real approval returns `"approved"`.
206
+
207
+ **Set the defaults where the tool is built, not in the prompt** — how long
208
+ somebody has to wake up is an operations decision, not the model's:
209
+
210
+ ```ts
211
+ await createM91ApprovalTool(client, {
212
+ severity: 'CRITICAL',
213
+ approveLabel: 'Deploy',
214
+ rejectLabel: 'Hold release',
215
+ timeoutSeconds: 900,
216
+ });
217
+ ```
218
+
219
+ ---
220
+
221
+ ## LangGraph
222
+
223
+ LangGraph knows how to stop a graph and resume it. What it has no opinion about
224
+ is how the human finds out it stopped. M91 is the part in between.
225
+
226
+ ```ts
227
+ import { M91InterruptNotifier } from '@msg91comm/m91/integrations/langgraph';
228
+
229
+ const notifier = new M91InterruptNotifier(client, { timeoutMs: 600_000 });
230
+
231
+ let result = await graph.invoke(state, config);
232
+ if (result.__interrupt__) {
233
+ const data = result.__interrupt__[0].value;
234
+ const decision = await notifier.notify({ title: data.question, customId: data.customId });
235
+ result = await graph.invoke(new Command({ resume: decision }), config);
236
+ }
237
+ ```
238
+
239
+ Or let the graph own it:
240
+
241
+ ```ts
242
+ import { createM91ApprovalNode } from '@msg91comm/m91/integrations/langgraph';
243
+
244
+ builder.addNode('approveRefund', createM91ApprovalNode(client, {
245
+ approveLabel: 'Process Refund',
246
+ rejectLabel: 'Hold Refund',
247
+ timeoutMs: 600_000,
248
+ }));
249
+ ```
250
+
251
+ The node reads `title` (or `question`) and `customId` from state, and writes
252
+ `{ decision, approved }` back for the next edge.
253
+
254
+ ---
255
+
256
+ ## Vercel AI SDK
257
+
258
+ ```ts
259
+ import { createM91ApprovalTool, createM91AlertTool } from '@msg91comm/m91/integrations/vercel-ai';
260
+
261
+ const { text } = await generateText({
262
+ model: openai('gpt-4o'),
263
+ tools: {
264
+ m91_request_approval: await createM91ApprovalTool(client),
265
+ m91_send_alert: await createM91AlertTool(client),
266
+ },
267
+ stopWhen: stepCountIs(10),
268
+ prompt: 'Verify the GDPR deletion request for acct_8821 and action it.',
269
+ });
270
+ ```
271
+
272
+ ---
273
+
274
+
275
+ ## Error handling
276
+
277
+ Branch on `.code` — a stable string — never on `.message`, which is written for
278
+ a person and is expected to change.
279
+
280
+ ```ts
281
+ import {
282
+ M91AuthError, M91ChannelEmptyError, M91DeliveryError, M91Error,
283
+ M91NetworkError, M91RateLimitError, M91ServerError, M91ValidationError,
284
+ } from '@msg91comm/m91';
285
+
286
+ try {
287
+ await client.send('Payments API is down', { severity: 'CRITICAL', customId: 'payments-down' });
288
+ } catch (error) {
289
+ if (error instanceof M91DeliveryError) {
290
+ // 2xx, and nobody's phone rang. The one failure a status code hides.
291
+ await pageBackupChannel({ unregistered: error.unregistered, noDevice: error.noDevice });
292
+ } else if (error instanceof M91ChannelEmptyError) {
293
+ log.error('M91 channel has no reachable members', error.unregistered);
294
+ } else if (error instanceof M91RateLimitError) {
295
+ // 60 sends a minute per link. Already retried once, at the server's own delay.
296
+ log.warn(`M91 rate limited, retry after ${error.retryAfter}s`);
297
+ } else if (error instanceof M91AuthError) {
298
+ log.error('M91 send link is invalid or has been revoked');
299
+ } else if (error instanceof M91ValidationError) {
300
+ log.error(`M91 rejected the alert: ${error.message}`, error.details);
301
+ } else if (error instanceof M91NetworkError || error instanceof M91ServerError) {
302
+ log.error('M91 unreachable', error);
303
+ } else if (error instanceof M91Error) {
304
+ log.error(`M91 failed: [${error.code}] ${error.message}`);
305
+ } else {
306
+ throw error;
307
+ }
308
+ }
309
+ ```
310
+
311
+ | Thrown | When |
312
+ |---|---|
313
+ | `M91ValidationError` | a field breaks a limit, locally or at the server (400, 413) |
314
+ | `M91AuthError` | the credential is invalid, expired or revoked (401) |
315
+ | `M91ForbiddenError` | authenticated but not allowed (403) |
316
+ | `M91NotFoundError` | absent — or present and not yours (404) |
317
+ | `M91ChannelEmptyError` | nobody on the channel can receive (409) |
318
+ | `M91RateLimitError` | over 60 sends a minute on this link (429) |
319
+ | `M91ServerError` | M91's fault (5xx), after the retries are spent |
320
+ | `M91NetworkError` | the request never got an answer |
321
+ | `M91DeliveryError` | 2xx, and no phone was reached |
322
+ | `M91TimeoutError` | `throwOnTimeout: true` and nobody answered |
323
+
324
+ **Retries, which happen before any of these reach you:** a 429 is retried once,
325
+ after exactly the delay the server asked for. A 5xx is retried three times, at
326
+ 1s, 2s and 4s. A 4xx is never retried — it is not transient. A network failure
327
+ is never retried either: a send that timed out may already have raised the
328
+ alert, and repeating it is how one condition wakes a team four times.
329
+
330
+ ---
331
+
332
+ ## The rules worth knowing
333
+
334
+ | | |
335
+ |---|---|
336
+ | **`customId` must be stable** | The same condition gets the same string every time — `disk-full-node7`, `refund-ORD-40122`. Built from a timestamp or a UUID it defeats deduplication (a check running every minute raises sixty alerts an hour) and leaves nothing stable to close by. |
337
+ | **`delivered` is not the roster** | It counts phones actually sent to. A channel of four whose device tokens are all stale answers 201 with `delivered: 0`. Assert on `alert.actuallyReachedSomeone`, not on the request succeeding. |
338
+ | **Read `decision`, never `responses[0]`** | Only a SHARED response sets `decision`. A PERSONAL one is an acknowledgement — reading it as an answer would let "Seen" mean "Approved". |
339
+ | **`isAutoClose` defaults to `false`** | The alert stays open until somebody closes it, even after a SHARED response claims it. Set it `true` when the first person taking it on really is the end of the matter. |
340
+ | **A bare send link raises nothing** | `GET /s/<token>` with no `title` is the connection test. That is what stops a link preview or a crawler waking a team, so it is a security boundary rather than a default. |
341
+ | **404 and 403 on `close()` are success** | Nothing left to close is the state you asked for. |
342
+ | **Never log the send link** | The token is in the path. The SDK redacts it in every string, error and tool result; do the same in your own logs. |
343
+ | **An API key cannot alert a team** | It provisions channels. Alerting one is the send link, always. |
344
+ | **Invite before you alert** | Alerting an uninvited number is a 404, never a silent invitation. Alerting somebody you *removed* is a 409 instead — their channel still exists, its roster does not. |
345
+ | **`delivered` is not an answer** | It means phones were sent to. Poll `decision` for what a human actually decided. |
346
+
347
+ ---
348
+
349
+ ## API reference
350
+
351
+ ### `new M91Client(sendLink, options?)`
352
+
353
+ `options`: `{ timeoutMs?, throwOnZeroDelivery?, headers?, fetchImpl?, sleepImpl? }`
354
+
355
+ | Method | |
356
+ |---|---|
357
+ | `verify(): Promise<ChannelInfo>` | Check the link and name the channel. Raises no alert. |
358
+ | `send(title, options?): Promise<AlertResponse>` | Raise an alert. |
359
+ | `getAlert(customIdOrAlertId): Promise<AlertDetail \| null>` | Read one back, by either id. |
360
+ | `waitForDecision(customId, options?): Promise<Decision \| null>` | Block until a SHARED response claims it. |
361
+ | `close({ customId? , alertId? }): Promise<boolean>` | Close it. Exactly one id. |
362
+ | `requestApproval(title, options): Promise<boolean>` | Send, wait, close. |
363
+
364
+ `SendOptions`: `{ description?, severity?, customId?, isAutoClose?, responseOptions? }`
365
+ `WaitOptions`: `{ timeoutMs?, pollIntervalMs?, throwOnTimeout? }`
366
+ `ApprovalOptions`: `{ customId, description?, severity?, approveLabel?, rejectLabel?, timeoutMs?, pollIntervalMs?, timeoutMeansReject? }`
367
+
368
+ ### `new M91Platform(apiKey, options?)`
369
+
370
+ | Method | |
371
+ |---|---|
372
+ | `verify(): Promise<ApiKeyInfo>` | Name the key and its owner. Alerts nobody. |
373
+ | `inviteRecipient(phone, label?): Promise<Recipient>` | Invite somebody. Idempotent. `label` is your product name. |
374
+ | `removeRecipient(phone): Promise<boolean>` | Stop alerting them. |
375
+ | `alertPerson(phone, title, options?): Promise<AlertResponse>` | Wake one person. |
376
+ | `getAlert(alertId, phone): Promise<AlertDetail \| null>` | Read what they decided. |
377
+ | `closeAlert(phone, { customId? , alertId? }): Promise<boolean>` | Close their alert. |
378
+ | `createChannel(name, alertOwner?): Promise<ProvisionedChannel>` | **Not idempotent.** Store the result. |
379
+ | `addMembers(channelId, phones): Promise<InviteResult>` | Up to 500 per call. |
380
+ | `removeMember(channelId, phone): Promise<boolean>` | Take them off the roster. |
381
+
382
+ ### `new M91URLBuilder(sendLink)`
383
+
384
+ | Method | |
385
+ |---|---|
386
+ | `build(title, options?): string` | One alert URL. |
387
+ | `buildMany(alerts): string[]` | One per spec. |
388
+ | `testUrl(): string` | The bare link. Raises nothing. |
389
+
390
+ ### Types
391
+
392
+ `Severity` · `Effect` · `AlertStatus` · `RecipientState` · `ResponseOption` ·
393
+ `Decision` · `AlertResponse` · `AlertDetail` · `ChannelInfo` · `Recipient` ·
394
+ `ProvisionedChannel` · `InviteResult` · `ApiKeyInfo`
395
+
396
+ Helpers: `isApproved(decision, approveLabel?)` · `validatePhone(phone)` ·
397
+ `redact(url)` · `buildAlertPayload(title, fields?)`
398
+
399
+ ---
400
+
401
+ ## Running the tests
402
+
403
+ ```bash
404
+ npm test
405
+ ```
406
+
407
+ Compiles with `tsc` and runs the suite on Node's built-in test runner — no test
408
+ framework to install. Every test swaps `fetch`; nothing here makes a network
409
+ call, and nothing wakes anybody.
410
+
411
+ For the SDK against a real socket — a live server, a real poll loop, a real
412
+ timeout — run the demo:
413
+
414
+ ```bash
415
+ cd ../demo && python3 run_node_demo.py
416
+ ```
417
+
418
+ ---
419
+
420
+ ## Examples
421
+
422
+ The `examples/` directory included with the SDK covers a basic alert, the approval
423
+ gate, a LangChain agent, the LangGraph `interrupt()` pattern, a Vercel AI SDK
424
+ agent, and webhook URLs for Grafana and friends.
425
+
426
+ Full API documentation: **<https://m91.msg91.com/docs>**
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The send-link client: raise an alert, read what was decided, close it.
3
+ *
4
+ * A send link is a credential in a URL. It goes in a Grafana webhook field, a
5
+ * cron, a CI job or an agent's environment, and it needs no header, no account
6
+ * and no library — which is why this client is a convenience over one URL
7
+ * rather than the only way in.
8
+ *
9
+ * Two things it does that a bare `fetch` does not, and both are why it exists:
10
+ *
11
+ * - **It checks that somebody was actually reached.** A 201 means the alert was
12
+ * filed, not that a phone rang.
13
+ * - **It reads the decision, not the responses.** Only a SHARED response decides.
14
+ */
15
+ import { type TransportOptions } from './http.js';
16
+ import { type AlertDetail, type AlertResponse, type ChannelInfo, type Decision, type ResponseOption, type Severity } from './models.js';
17
+ /**
18
+ * Below this, polling is a busy loop against a rate-limited endpoint and buys
19
+ * nothing: a human waking up and reaching for a phone is not a sub-second event.
20
+ */
21
+ export declare const MIN_POLL_INTERVAL_MS = 2000;
22
+ export interface SendOptions {
23
+ description?: string;
24
+ /**
25
+ * LOW and MEDIUM deliver at alarm volume through Do Not Disturb. **HIGH and
26
+ * CRITICAL also place a voice call** if nobody answers in 45 seconds, so they
27
+ * are never the default — "I didn't specify" must not mean "ring everyone".
28
+ */
29
+ severity?: Severity;
30
+ /**
31
+ * Your own stable id for *the condition*, not for this occurrence. The same
32
+ * disk filling up is the same `customId` every time. Built from a timestamp
33
+ * or a UUID it defeats deduplication — a check running every minute raises
34
+ * sixty alerts an hour — and leaves nothing stable to close by.
35
+ */
36
+ customId?: string;
37
+ /**
38
+ * Left false, the alert stays open until somebody closes it, even after a
39
+ * SHARED response claims it. Set true when the first person taking it on is
40
+ * genuinely the end of the matter.
41
+ */
42
+ isAutoClose?: boolean;
43
+ responseOptions?: Array<ResponseOption | string>;
44
+ /**
45
+ * Whether the free-text "Something else" reply is offered. Tri-state: leave
46
+ * it unset and the server's default (on) stands.
47
+ */
48
+ other?: boolean;
49
+ /** https links to files the alert is about. URLs, never bytes. */
50
+ attachments?: string[];
51
+ /** Where the outcome is POSTed when somebody answers. */
52
+ callbackUrl?: string;
53
+ }
54
+ export interface WaitOptions {
55
+ timeoutMs?: number;
56
+ pollIntervalMs?: number;
57
+ throwOnTimeout?: boolean;
58
+ }
59
+ export interface ApprovalOptions {
60
+ customId: string;
61
+ description?: string;
62
+ severity?: Severity;
63
+ approveLabel?: string;
64
+ rejectLabel?: string;
65
+ timeoutMs?: number;
66
+ pollIntervalMs?: number;
67
+ /** A timeout returns false. There is deliberately no option making it true. */
68
+ timeoutMeansReject?: boolean;
69
+ }
70
+ export interface ClientOptions extends TransportOptions {
71
+ /**
72
+ * Throw `M91DeliveryError` when a send reaches nobody. On by default, because
73
+ * the silent version of this failure is an alert nobody ever hears about.
74
+ */
75
+ throwOnZeroDelivery?: boolean;
76
+ }
77
+ /** Alerts one channel, through one send link. */
78
+ export declare class M91Client {
79
+ readonly sendLink: string;
80
+ readonly throwOnZeroDelivery: boolean;
81
+ private readonly http;
82
+ private readonly sleep;
83
+ private readonly now;
84
+ constructor(sendLink: string, options?: ClientOptions);
85
+ /** Redacted, always: a token in a log, a trace or a crash report can wake a team. */
86
+ toString(): string;
87
+ private url;
88
+ /**
89
+ * Check the link works, and see which channel it points at.
90
+ *
91
+ * **Raises no alert.** Safe on startup, in a health check, or while somebody
92
+ * is pasting the URL into a settings page — which is the point: a mistyped
93
+ * link otherwise shows "connected" and only reveals itself days later, when
94
+ * an alert silently fails to fire.
95
+ */
96
+ verify(): Promise<ChannelInfo>;
97
+ /** Raise an alert on this channel. */
98
+ send(title: string, options?: SendOptions): Promise<AlertResponse>;
99
+ /**
100
+ * Read one alert back. `null` when there is no such alert on this channel.
101
+ *
102
+ * The id is **either** the `customId` you invented or the `_id` the send
103
+ * returned — a caller knows one of them and should not have to say which.
104
+ */
105
+ getAlert(customIdOrAlertId: string): Promise<AlertDetail | null>;
106
+ /**
107
+ * Wait until somebody takes the alert on, or the clock runs out.
108
+ *
109
+ * Resolves with the SHARED response that claimed it. **PERSONAL responses
110
+ * never end this wait** — a "Seen" is an acknowledgement, not an answer, and
111
+ * treating it as one is the mistake `decision` exists to prevent.
112
+ *
113
+ * Stops early and resolves `null` if the alert is closed without ever being
114
+ * claimed: there is nothing left to wait for.
115
+ */
116
+ waitForDecision(customId: string, options?: WaitOptions): Promise<Decision | null>;
117
+ /**
118
+ * Close an alert this link raised. Always `true` when there is nothing open.
119
+ *
120
+ * A monitor that sees the problem heal closes it itself rather than waiting
121
+ * for a person to wake up and notice.
122
+ *
123
+ * **404 and 403 are success.** "No such alert" and "not yours to close" both
124
+ * mean the alert is not open on this channel, which is the state the caller
125
+ * asked for. Anything else would make every close a branch, and every retry
126
+ * after a dropped response a false failure.
127
+ */
128
+ close(options: {
129
+ customId?: string;
130
+ alertId?: string;
131
+ }): Promise<boolean>;
132
+ /**
133
+ * Ask a human, on their phone, and wait for the answer. `true` = approved.
134
+ *
135
+ * The shape of every human-in-the-loop gate: raise the question, block, act
136
+ * on the answer, close the alert either way. Severity defaults to HIGH here —
137
+ * unlike `send` — because something is *waiting* on the answer, so a phone
138
+ * call after 45 seconds of silence is the point rather than an escalation.
139
+ *
140
+ * Both labels are SHARED: approving and rejecting are each a final answer
141
+ * that settles the question for everybody, and neither is an "I saw it".
142
+ *
143
+ * On timeout with `timeoutMeansReject` (the default) it resolves false —
144
+ * nobody answered, so nothing consequential happens. Set it false to get
145
+ * `M91TimeoutError` instead. There is deliberately no way to make a timeout
146
+ * mean approved.
147
+ */
148
+ requestApproval(title: string, options: ApprovalOptions): Promise<boolean>;
149
+ }
150
+ /**
151
+ * A 2xx that woke nobody is a failure, and only the caller can decide what to do.
152
+ *
153
+ * `delivered` counts phones actually sent to, so this fires when a channel's
154
+ * devices are all stale or its members have not installed the app — a case that
155
+ * answers 201 and looks exactly like success.
156
+ */
157
+ export declare function checkDelivery(alert: AlertResponse, enabled: boolean): void;
158
+ /**
159
+ * Whether one poll ends the wait. Pure, so the rule lives in one place.
160
+ */
161
+ export declare function waitVerdict(detail: AlertDetail | null): {
162
+ stop: boolean;
163
+ decision: Decision | null;
164
+ };
165
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"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"}