@msg91comm/m91 0.0.0-stage → 1.2.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 (100) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +21 -0
  3. package/README.md +647 -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/claude-agent-sdk.d.ts +63 -0
  21. package/dist/src/integrations/claude-agent-sdk.d.ts.map +1 -0
  22. package/dist/src/integrations/claude-agent-sdk.js +109 -0
  23. package/dist/src/integrations/claude-agent-sdk.js.map +1 -0
  24. package/dist/src/integrations/google-adk.d.ts +47 -0
  25. package/dist/src/integrations/google-adk.d.ts.map +1 -0
  26. package/dist/src/integrations/google-adk.js +84 -0
  27. package/dist/src/integrations/google-adk.js.map +1 -0
  28. package/dist/src/integrations/index.d.ts +26 -0
  29. package/dist/src/integrations/index.d.ts.map +1 -0
  30. package/dist/src/integrations/index.js +25 -0
  31. package/dist/src/integrations/index.js.map +1 -0
  32. package/dist/src/integrations/langchain.d.ts +94 -0
  33. package/dist/src/integrations/langchain.d.ts.map +1 -0
  34. package/dist/src/integrations/langchain.js +129 -0
  35. package/dist/src/integrations/langchain.js.map +1 -0
  36. package/dist/src/integrations/langgraph.d.ts +79 -0
  37. package/dist/src/integrations/langgraph.d.ts.map +1 -0
  38. package/dist/src/integrations/langgraph.js +114 -0
  39. package/dist/src/integrations/langgraph.js.map +1 -0
  40. package/dist/src/integrations/mastra.d.ts +75 -0
  41. package/dist/src/integrations/mastra.d.ts.map +1 -0
  42. package/dist/src/integrations/mastra.js +109 -0
  43. package/dist/src/integrations/mastra.js.map +1 -0
  44. package/dist/src/integrations/openai-agents.d.ts +61 -0
  45. package/dist/src/integrations/openai-agents.d.ts.map +1 -0
  46. package/dist/src/integrations/openai-agents.js +127 -0
  47. package/dist/src/integrations/openai-agents.js.map +1 -0
  48. package/dist/src/integrations/specs.d.ts +173 -0
  49. package/dist/src/integrations/specs.d.ts.map +1 -0
  50. package/dist/src/integrations/specs.js +217 -0
  51. package/dist/src/integrations/specs.js.map +1 -0
  52. package/dist/src/integrations/vercel-ai.d.ts +74 -0
  53. package/dist/src/integrations/vercel-ai.d.ts.map +1 -0
  54. package/dist/src/integrations/vercel-ai.js +102 -0
  55. package/dist/src/integrations/vercel-ai.js.map +1 -0
  56. package/dist/src/integrations/zod-shapes.d.ts +19 -0
  57. package/dist/src/integrations/zod-shapes.d.ts.map +1 -0
  58. package/dist/src/integrations/zod-shapes.js +44 -0
  59. package/dist/src/integrations/zod-shapes.js.map +1 -0
  60. package/dist/src/models.d.ts +274 -0
  61. package/dist/src/models.d.ts.map +1 -0
  62. package/dist/src/models.js +383 -0
  63. package/dist/src/models.js.map +1 -0
  64. package/dist/src/platform.d.ts +176 -0
  65. package/dist/src/platform.d.ts.map +1 -0
  66. package/dist/src/platform.js +286 -0
  67. package/dist/src/platform.js.map +1 -0
  68. package/dist/src/url-builder.d.ts +65 -0
  69. package/dist/src/url-builder.d.ts.map +1 -0
  70. package/dist/src/url-builder.js +167 -0
  71. package/dist/src/url-builder.js.map +1 -0
  72. package/examples/approval-gate.ts +35 -0
  73. package/examples/basic-alert.ts +53 -0
  74. package/examples/claude-agent-sdk.ts +23 -0
  75. package/examples/deep-agents.ts +31 -0
  76. package/examples/google-adk.ts +39 -0
  77. package/examples/langchain-agent.ts +65 -0
  78. package/examples/langgraph-hitl.ts +70 -0
  79. package/examples/mastra.ts +41 -0
  80. package/examples/openai-agents.ts +33 -0
  81. package/examples/vercel-ai-agent.ts +34 -0
  82. package/examples/webhook-urls.ts +44 -0
  83. package/package.json +135 -4
  84. package/src/client.ts +324 -0
  85. package/src/exceptions.ts +229 -0
  86. package/src/http.ts +174 -0
  87. package/src/index.ts +91 -0
  88. package/src/integrations/claude-agent-sdk.ts +158 -0
  89. package/src/integrations/google-adk.ts +122 -0
  90. package/src/integrations/index.ts +27 -0
  91. package/src/integrations/langchain.ts +207 -0
  92. package/src/integrations/langgraph.ts +142 -0
  93. package/src/integrations/mastra.ts +178 -0
  94. package/src/integrations/openai-agents.ts +173 -0
  95. package/src/integrations/specs.ts +328 -0
  96. package/src/integrations/vercel-ai.ts +151 -0
  97. package/src/integrations/zod-shapes.ts +55 -0
  98. package/src/models.ts +660 -0
  99. package/src/platform.ts +343 -0
  100. package/src/url-builder.ts +225 -0
package/README.md CHANGED
@@ -1,3 +1,648 @@
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` (v5 or later) |
32
+ | `@msg91comm/m91/integrations/openai-agents` | `@openai/agents` |
33
+ | `@msg91comm/m91/integrations/claude-agent-sdk` | `@anthropic-ai/claude-agent-sdk`, `zod` |
34
+ | `@msg91comm/m91/integrations/google-adk` | `@google/adk`, `zod` |
35
+ | `@msg91comm/m91/integrations/mastra` | `@mastra/core`, `zod` |
36
+
37
+ ---
38
+
39
+ ## Quick start
40
+
41
+ You need a **send link** — `https://m91-api.msg91.com/s/<token>` — from the channel
42
+ in the M91 app. The token is in the URL path, so the link *is* the credential:
43
+ keep it in the environment, never in the repository.
44
+
45
+ ```ts
46
+ import { M91Client } from '@msg91comm/m91';
47
+
48
+ const client = new M91Client(process.env.M91_SEND_LINK!);
49
+
50
+ await client.verify(); // raises nothing; names the channel
51
+ await client.send('Database disk 90% full on node-7', {
52
+ severity: 'HIGH',
53
+ customId: 'disk-full-node7', // stable: the condition, not the moment
54
+ });
55
+ await client.close({ customId: 'disk-full-node7' }); // when the disk has been cleared
56
+ ```
57
+
58
+ `verify()` is worth calling on startup. A mistyped link otherwise looks
59
+ perfectly healthy until the night it has to work.
60
+
61
+ ---
62
+
63
+ ## The approval gate
64
+
65
+ The reason an agent wants this: a question that blocks until a person answers on
66
+ their phone.
67
+
68
+ ```ts
69
+ const approved = await client.requestApproval(
70
+ 'Agent wants to refund ₹48,000 on order ORD-40122',
71
+ {
72
+ description: 'Customer says it never arrived. Courier shows delivered, signed for.',
73
+ customId: 'refund-ORD-40122',
74
+ timeoutMs: 300_000,
75
+ },
76
+ );
77
+
78
+ if (approved) await processRefund();
79
+ ```
80
+
81
+ One call raises the alert, waits, and closes it afterwards. It resolves `true`
82
+ only when somebody tapped **Approve**. A rejection, a timeout and an unreachable
83
+ channel all resolve `false` or throw — there is no configuration under which
84
+ silence means yes.
85
+
86
+ ### Asking for a reason, not just a tap
87
+
88
+ An option can require something before it can be chosen. `required` blocks the
89
+ button until it is supplied; `optional` offers a skippable field.
90
+
91
+ ```ts
92
+ await client.send('Agent wants to refund ₹48,000 on order ORD-40122', {
93
+ customId: 'refund-ORD-40122',
94
+ responseOptions: [
95
+ { label: 'Approve', effect: 'SHARED' },
96
+ { label: 'Reject', effect: 'SHARED', required: ['note'] },
97
+ { label: 'Need the delivery photo', effect: 'SHARED', optional: ['attachment'] },
98
+ ],
99
+ });
100
+ ```
101
+
102
+ Only two things can be asked for: `'note'` and `'attachment'`. Requiring a note
103
+ on the rejection is the difference between your code learning *no* and learning
104
+ *why* — worth it on any branch that has to explain itself to a customer later.
105
+
106
+ Two more fields on `send`:
107
+
108
+ - **`attachments`** — https links to files the alert is about, shown on the
109
+ alert screen. URLs, never bytes.
110
+ - **`other`** — whether the free-text "Something else" reply is offered. Leave
111
+ it unset and the server's default (on) stands; set `false` when the answer
112
+ must be one of your options.
113
+
114
+ ### `callbackUrl` instead of polling
115
+
116
+ `waitForDecision` and `requestApproval` poll. For a service that is already
117
+ listening on a URL, hand M91 the URL instead and the outcome is POSTed to it
118
+ when somebody answers:
119
+
120
+ ```ts
121
+ await client.send('Agent wants to refund ₹48,000 on order ORD-40122', {
122
+ customId: 'refund-ORD-40122',
123
+ callbackUrl: 'https://your-service.example.com/hooks/m91',
124
+ responseOptions: [{ label: 'Approve', effect: 'SHARED' },
125
+ { label: 'Reject', effect: 'SHARED', required: ['note'] }],
126
+ });
127
+ ```
128
+
129
+ **It will not reach your laptop.** The callback is fetched by M91's servers and
130
+ private addresses are refused — `localhost`, `127.0.0.1`, `10.x`, `192.168.x`
131
+ and the rest — so local development and anything inside a VPC has to poll. Use
132
+ a tunnel, or keep `waitForDecision` for development and the callback in
133
+ production.
134
+
135
+ You still own the timeout either way. There is no server-side expiry: decide
136
+ what silence means before you send, and make it the safe answer.
137
+
138
+ ---
139
+
140
+ ## Severity
141
+
142
+ | | Delivery | Voice call |
143
+ |---|---|---|
144
+ | `LOW` | Rings through Do Not Disturb, repeats until answered | no |
145
+ | `MEDIUM` — the default | Rings through Do Not Disturb, repeats until answered | no |
146
+ | `HIGH` | The same | after 45s with no answer |
147
+ | `CRITICAL` | The same | after 45s with no answer |
148
+
149
+ MEDIUM is the default because HIGH dials phones, and "I didn't specify" must not
150
+ mean "ring everyone". `requestApproval` defaults to HIGH instead — something is
151
+ waiting on the answer, so a call after 45 seconds of silence is the point.
152
+
153
+ ---
154
+
155
+ ## Alert URLs
156
+
157
+ For anything whose only integration field is "URL to call": Grafana, Sentry,
158
+ UptimeRobot, Zapier, a GitHub Action, a cron `curl`, an iOS Shortcut, a browser
159
+ bookmark.
160
+
161
+ ```ts
162
+ import { M91URLBuilder } from '@msg91comm/m91';
163
+
164
+ const builder = new M91URLBuilder(process.env.M91_SEND_LINK!);
165
+
166
+ console.log(builder.build('CPU above 95% on prod-server-3', {
167
+ severity: 'HIGH',
168
+ customId: 'cpu-high-prod3',
169
+ respond: ['On it', 'Escalate'],
170
+ }));
171
+ // https://m91-api.msg91.com/s/TOKEN?title=CPU+above+95%25+on+prod-server-3&severity=HIGH
172
+ // &customId=cpu-high-prod3&respond=On+it%2CEscalate
173
+ ```
174
+
175
+ Paste that into Grafana's webhook field and it works — no client, no headers, no
176
+ code.
177
+
178
+ In `respond`, the **first label is SHARED** and the rest are PERSONAL. Up to six
179
+ in a link. To make a later one SHARED too, pass `{ label: 'Also on it' }`
180
+ instead of the bare string, or add a trailing `!`.
181
+
182
+ `builder.testUrl()` returns the bare link. Opening it **raises nothing** and
183
+ answers with the channel's name, which is also why a link preview, a crawler or
184
+ a mail scanner touching a pasted URL wakes nobody.
185
+
186
+ ---
187
+
188
+ ## Platform API — alerting one person
189
+
190
+ For a product that discovers its users at runtime, where a per-team send link
191
+ does not fit. Needs an API key (`m91sk_…`), minted from the app.
192
+
193
+ ```ts
194
+ import { M91Platform } from '@msg91comm/m91';
195
+
196
+ const platform = new M91Platform(process.env.M91_API_KEY!);
197
+
198
+ await platform.verify(); // names the key and its owner. Alerts nobody.
199
+
200
+ // Idempotent — this belongs in your login handler, not a migration script.
201
+ // The label is your PRODUCT name: it is what she sees on the alert.
202
+ const recipient = await platform.inviteRecipient('919876543210', 'Acme CRM');
203
+
204
+ if (recipient.state === 'accepted') {
205
+ await platform.alertPerson('919876543210', 'Your nightly export failed', {
206
+ customId: 'export-fail-priya-2026-09',
207
+ });
208
+ }
209
+ ```
210
+
211
+ `label` is **what the person sees as the name of the thing alerting them** — your
212
+ product, not their name. It defaults to `"Direct alerts"`, and **every invite
213
+ overwrites it**, so pass the same constant on every login rather than letting a
214
+ later call reset it.
215
+
216
+ Only an **accepted** recipient can be woken. `pending` means they have been
217
+ asked and have not answered — nag them in your own UI rather than re-inviting in
218
+ a loop. `declined` is terminal: no API re-invites a declined number, and only
219
+ they can undo it.
220
+
221
+ An API key can also *provision* a team channel — `createChannel`, `addMembers`,
222
+ `removeMember` — but it **cannot alert one**. Alerting a team is always the send
223
+ link `createChannel` returns. A leaked key can restaff a channel; it cannot ring
224
+ a single phone in it.
225
+
226
+ `alertPerson` throws `M91DeliveryError` on a 2xx that reached nobody, same as
227
+ `send`. It matters more here: a direct alert has a roster of one, so a single
228
+ stale device token is the difference between waking somebody and waking nobody.
229
+
230
+ The cases an API key exists for — per-user alerting, per-tenant channels, roster
231
+ sync — and the much larger set where a send link is the right answer, are worked
232
+ through at **<https://m91-api.msg91.com/docs>**.
233
+
234
+ ---
235
+
236
+ ## LangChain
237
+
238
+ An agent that must not act alone needs a human, and the hard part is not pausing
239
+ — it is **reaching somebody**. `interrupt()` stops the graph and waits for a
240
+ resume that only arrives if a person happens to be watching a dashboard. M91
241
+ fills that half: the tool call rings every phone on a channel at full volume,
242
+ through silent mode and Do Not Disturb, escalating to a voice call if nobody
243
+ answers. The tool returns `"approved"` only when a human tapped **Approve**;
244
+ a rejection, a timeout and an unreachable channel are all a no.
245
+
246
+ ```ts
247
+ import { M91Client } from '@msg91comm/m91';
248
+ import { createM91ApprovalTool, createM91AlertTool } from '@msg91comm/m91/integrations/langchain';
249
+
250
+ const client = new M91Client(process.env.M91_SEND_LINK!);
251
+
252
+ const tools = [
253
+ await createM91ApprovalTool(client), // blocks for a human
254
+ await createM91AlertTool(client), // wakes somebody, carries on
255
+ ...myTools,
256
+ ];
257
+ const agent = createAgent({ model: 'openai:gpt-5-mini', tools, systemPrompt: SYSTEM }); // from 'langchain'
258
+ ```
259
+
260
+ The factories are async because `@langchain/core` is imported only when you call
261
+ one — that is what keeps `npm install @msg91comm/m91` at zero dependencies, which matters
262
+ for a package whose job is to work when everything else is on fire.
263
+
264
+ The tool descriptions tell the model when to reach for each one. Your system
265
+ prompt still has to say that approval is **required** before irreversible
266
+ actions, and to list them — see `examples/langchain-agent.ts`, included with the SDK.
267
+
268
+ `createM91ApprovalTool` returns a tool that answers `"approved"`, `"rejected"`
269
+ or `"error: …"`. Nothing but a real approval returns `"approved"`.
270
+
271
+ **Set the defaults where the tool is built, not in the prompt** — how long
272
+ somebody has to wake up is an operations decision, not the model's:
273
+
274
+ ```ts
275
+ await createM91ApprovalTool(client, {
276
+ severity: 'CRITICAL',
277
+ approveLabel: 'Deploy',
278
+ rejectLabel: 'Hold release',
279
+ timeoutSeconds: 900,
280
+ });
281
+ ```
282
+
283
+ ---
284
+
285
+ ### `humanInTheLoopMiddleware` and Deep Agents
286
+
287
+ LangChain's `humanInTheLoopMiddleware` (and Deep Agents' `interruptOn`, which is
288
+ built on it) pauses the run before the tools you name and waits for
289
+ `{ decisions: [...] }`. `invokeWithM91Approvals` answers each pause on a phone
290
+ and resumes the same thread:
291
+
292
+ ```ts
293
+ import { createDeepAgent } from 'deepagents';
294
+ import { MemorySaver } from '@langchain/langgraph';
295
+ import { invokeWithM91Approvals } from '@msg91comm/m91/integrations/langchain';
296
+
297
+ const agent = createDeepAgent({
298
+ interruptOn: { write_file: true, edit_file: true, execute: true },
299
+ checkpointer: new MemorySaver(),
300
+ });
301
+
302
+ const result = await invokeWithM91Approvals(
303
+ agent,
304
+ { messages: [{ role: 'user', content: 'Write the release notes to RELEASE.md.' }] },
305
+ { configurable: { thread_id: 'release-v2.4' } },
306
+ client,
307
+ );
308
+ ```
309
+
310
+ The same call works for `createAgent({ middleware: [humanInTheLoopMiddleware({ interruptOn })] })`.
311
+ Running your own loop? `resolveHITLWithM91(result.__interrupt__[0].value, client)`
312
+ returns the `{ decisions }` to pass to `new Command({ resume })`.
313
+
314
+ ---
315
+
316
+ ## LangGraph
317
+
318
+ LangGraph knows how to stop a graph and resume it. What it has no opinion about
319
+ is how the human finds out it stopped. M91 is the part in between.
320
+
321
+ ```ts
322
+ import { M91InterruptNotifier } from '@msg91comm/m91/integrations/langgraph';
323
+
324
+ const notifier = new M91InterruptNotifier(client, { timeoutMs: 600_000 });
325
+
326
+ let result = await graph.invoke(state, config);
327
+ if (result.__interrupt__) {
328
+ const data = result.__interrupt__[0].value;
329
+ const decision = await notifier.notify({ title: data.question, customId: data.customId });
330
+ result = await graph.invoke(new Command({ resume: decision }), config);
331
+ }
332
+ ```
333
+
334
+ Or let the graph own it:
335
+
336
+ ```ts
337
+ import { createM91ApprovalNode } from '@msg91comm/m91/integrations/langgraph';
338
+
339
+ builder.addNode('approveRefund', createM91ApprovalNode(client, {
340
+ approveLabel: 'Process Refund',
341
+ rejectLabel: 'Hold Refund',
342
+ timeoutMs: 600_000,
343
+ }));
344
+ ```
345
+
346
+ The node reads `title` (or `question`) and `customId` from state, and writes
347
+ `{ decision, approved }` back for the next edge.
348
+
349
+ ---
350
+
351
+ ## Vercel AI SDK
352
+
353
+ Two ways in. **Tools** the model calls when it decides to ask:
354
+
355
+ ```ts
356
+ import { generateText, isStepCount } from 'ai';
357
+ import { createM91Tools } from '@msg91comm/m91/integrations/vercel-ai';
358
+
359
+ const { text } = await generateText({
360
+ model: 'openai/gpt-5-mini',
361
+ tools: { ...(await createM91Tools(client)) },
362
+ stopWhen: isStepCount(10),
363
+ prompt: 'Verify the GDPR deletion request for acct_8821 and action it.',
364
+ });
365
+ ```
366
+
367
+ **A gate** on your own tools, through the AI SDK's `toolApproval` option. The
368
+ named tools ring a phone before they run; the model cannot skip it.
369
+
370
+ ```ts
371
+ import { createM91ToolApproval } from '@msg91comm/m91/integrations/vercel-ai';
372
+
373
+ await generateText({
374
+ model: 'openai/gpt-5-mini',
375
+ tools: { issueRefund, deleteUser },
376
+ toolApproval: createM91ToolApproval(client, { tools: ['issueRefund', 'deleteUser'] }),
377
+ stopWhen: isStepCount(10),
378
+ prompt: 'Refund ORD-40122.',
379
+ });
380
+ ```
381
+
382
+ ---
383
+
384
+ ## OpenAI Agents SDK
385
+
386
+ Mark risky tools `needsApproval: true`. `runWithM91Approvals` answers every
387
+ pause on a phone and resumes the same run.
388
+
389
+ ```ts
390
+ import { Agent, tool } from '@openai/agents';
391
+ import { runWithM91Approvals } from '@msg91comm/m91/integrations/openai-agents';
392
+
393
+ const issueRefund = tool({
394
+ name: 'issue_refund',
395
+ description: 'Refund an order.',
396
+ parameters: z.object({ orderId: z.string(), amount: z.number() }),
397
+ needsApproval: true,
398
+ execute: async ({ orderId, amount }) => refunds.create(orderId, amount),
399
+ });
400
+
401
+ const agent = new Agent({ name: 'Support', instructions: 'Resolve refund requests.', tools: [issueRefund] });
402
+ const result = await runWithM91Approvals(agent, 'Refund ORD-40122, it never arrived.', client);
403
+ console.log(result.finalOutput);
404
+ ```
405
+
406
+ Running your own loop? `approveInterruptionsWithM91(result, client)` decides the
407
+ pending approvals on `result.state`; then call `run(agent, result.state)`.
408
+ `createM91Tools(client)` gives the agent the two tools as well.
409
+
410
+ ---
411
+
412
+ ## Claude Agent SDK
413
+
414
+ ```ts
415
+ import { query } from '@anthropic-ai/claude-agent-sdk';
416
+ import { createM91CanUseTool, createM91McpServer } from '@msg91comm/m91/integrations/claude-agent-sdk';
417
+
418
+ for await (const message of query({
419
+ prompt: 'Delete the stale feature branches and deploy main.',
420
+ options: {
421
+ // Bash and Write ring a phone first; every other tool runs as normal.
422
+ canUseTool: createM91CanUseTool(client, { tools: ['Bash', 'Write'] }),
423
+ // Optional: let Claude decide to ask, or to wake somebody.
424
+ mcpServers: { m91: await createM91McpServer(client) },
425
+ },
426
+ })) {
427
+ if (message.type === 'result') console.log(message);
428
+ }
429
+ ```
430
+
431
+ `canUseTool` only runs when the SDK would otherwise ask. To gate a tool in every
432
+ permission mode, including `bypassPermissions`, pass
433
+ `hooks: { PreToolUse: createM91PreToolUseHook(client, { tools: ['Bash'] }) }`.
434
+
435
+ ---
436
+
437
+ ## Google ADK
438
+
439
+ ```ts
440
+ import { LlmAgent } from '@google/adk';
441
+ import { createM91BeforeToolCallback, createM91Tools } from '@msg91comm/m91/integrations/google-adk';
442
+
443
+ const agent = new LlmAgent({
444
+ name: 'support_agent',
445
+ model: 'gemini-2.5-flash',
446
+ instruction: 'Resolve refund requests.',
447
+ tools: [issueRefund, ...(await createM91Tools(client))],
448
+ // issue_refund rings a phone first. A rejection skips the tool and tells the agent why.
449
+ beforeToolCallback: createM91BeforeToolCallback(client, { tools: ['issue_refund'] }),
450
+ });
451
+ ```
452
+
453
+ ---
454
+
455
+ ## Mastra
456
+
457
+ ```ts
458
+ import { Agent } from '@mastra/core/agent';
459
+ import { createTool } from '@mastra/core/tools';
460
+ import { generateWithM91Approvals } from '@msg91comm/m91/integrations/mastra';
461
+
462
+ const issueRefund = createTool({
463
+ id: 'issue-refund',
464
+ description: 'Refund an order.',
465
+ inputSchema: z.object({ orderId: z.string(), amount: z.number() }),
466
+ requireApproval: true,
467
+ execute: async ({ orderId, amount }) => refunds.create(orderId, amount),
468
+ });
469
+
470
+ const agent = new Agent({
471
+ id: 'support', name: 'Support', instructions: 'Resolve refund requests.',
472
+ model: 'openai/gpt-5-mini', tools: { issueRefund },
473
+ });
474
+
475
+ const output = await generateWithM91Approvals(agent, 'Refund ORD-40122.', client);
476
+ console.log(output.text);
477
+ ```
478
+
479
+ Mastra keeps suspended runs in storage, so configure a storage provider on your
480
+ `Mastra` instance.
481
+
482
+ ---
483
+
484
+ ### Which tools ring a phone
485
+
486
+ Every gate takes `tools`: a list of tool names, or a predicate
487
+ (`(name, input) => boolean`). Gates fail closed: a rejection, no answer within
488
+ `timeoutSeconds` (default 300) or an M91 error means the tool does not run, and
489
+ the agent is told why. Write the lock-screen text yourself with
490
+ `describe: (call) => ({ title, description })`.
491
+
492
+ ---
493
+
494
+ ## Error handling
495
+
496
+ Branch on `.code` — a stable string — never on `.message`, which is written for
497
+ a person and is expected to change.
498
+
499
+ ```ts
500
+ import {
501
+ M91AuthError, M91ChannelEmptyError, M91DeliveryError, M91Error,
502
+ M91NetworkError, M91RateLimitError, M91ServerError, M91ValidationError,
503
+ } from '@msg91comm/m91';
504
+
505
+ try {
506
+ await client.send('Payments API is down', { severity: 'CRITICAL', customId: 'payments-down' });
507
+ } catch (error) {
508
+ if (error instanceof M91DeliveryError) {
509
+ // 2xx, and nobody's phone rang. The one failure a status code hides.
510
+ await pageBackupChannel({ unregistered: error.unregistered, noDevice: error.noDevice });
511
+ } else if (error instanceof M91ChannelEmptyError) {
512
+ log.error('M91 channel has no reachable members', error.unregistered);
513
+ } else if (error instanceof M91RateLimitError) {
514
+ // 60 sends a minute per link. Already retried once, at the server's own delay.
515
+ log.warn(`M91 rate limited, retry after ${error.retryAfter}s`);
516
+ } else if (error instanceof M91AuthError) {
517
+ log.error('M91 send link is invalid or has been revoked');
518
+ } else if (error instanceof M91ValidationError) {
519
+ log.error(`M91 rejected the alert: ${error.message}`, error.details);
520
+ } else if (error instanceof M91NetworkError || error instanceof M91ServerError) {
521
+ log.error('M91 unreachable', error);
522
+ } else if (error instanceof M91Error) {
523
+ log.error(`M91 failed: [${error.code}] ${error.message}`);
524
+ } else {
525
+ throw error;
526
+ }
527
+ }
528
+ ```
529
+
530
+ | Thrown | When |
531
+ |---|---|
532
+ | `M91ValidationError` | a field breaks a limit, locally or at the server (400, 413) |
533
+ | `M91AuthError` | the credential is invalid, expired or revoked (401) |
534
+ | `M91ForbiddenError` | authenticated but not allowed (403) |
535
+ | `M91NotFoundError` | absent — or present and not yours (404) |
536
+ | `M91ChannelEmptyError` | nobody on the channel can receive (409) |
537
+ | `M91RateLimitError` | over 60 sends a minute on this link (429) |
538
+ | `M91ServerError` | M91's fault (5xx), after the retries are spent |
539
+ | `M91NetworkError` | the request never got an answer |
540
+ | `M91DeliveryError` | 2xx, and no phone was reached |
541
+ | `M91TimeoutError` | `throwOnTimeout: true` and nobody answered |
542
+
543
+ **Retries, which happen before any of these reach you:** a 429 is retried once,
544
+ after exactly the delay the server asked for. A 5xx is retried three times, at
545
+ 1s, 2s and 4s. A 4xx is never retried — it is not transient. A network failure
546
+ is never retried either: a send that timed out may already have raised the
547
+ alert, and repeating it is how one condition wakes a team four times.
548
+
549
+ ---
550
+
551
+ ## The rules worth knowing
552
+
553
+ | | |
554
+ |---|---|
555
+ | **`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. |
556
+ | **`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. |
557
+ | **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". |
558
+ | **`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. |
559
+ | **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. |
560
+ | **404 and 403 on `close()` are success** | Nothing left to close is the state you asked for. |
561
+ | **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. |
562
+ | **An API key cannot alert a team** | It provisions channels. Alerting one is the send link, always. |
563
+ | **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. |
564
+ | **`delivered` is not an answer** | It means phones were sent to. Poll `decision` for what a human actually decided. |
565
+
566
+ ---
567
+
568
+ ## API reference
569
+
570
+ ### `new M91Client(sendLink, options?)`
571
+
572
+ `options`: `{ timeoutMs?, throwOnZeroDelivery?, headers?, fetchImpl?, sleepImpl? }`
573
+
574
+ | Method | |
575
+ |---|---|
576
+ | `verify(): Promise<ChannelInfo>` | Check the link and name the channel. Raises no alert. |
577
+ | `send(title, options?): Promise<AlertResponse>` | Raise an alert. |
578
+ | `getAlert(customIdOrAlertId): Promise<AlertDetail \| null>` | Read one back, by either id. |
579
+ | `waitForDecision(customId, options?): Promise<Decision \| null>` | Block until a SHARED response claims it. |
580
+ | `close({ customId? , alertId? }): Promise<boolean>` | Close it. Exactly one id. |
581
+ | `requestApproval(title, options): Promise<boolean>` | Send, wait, close. |
582
+
583
+ `SendOptions`: `{ description?, severity?, customId?, isAutoClose?, responseOptions?, other?, attachments?, callbackUrl? }`
584
+ `WaitOptions`: `{ timeoutMs?, pollIntervalMs?, throwOnTimeout? }`
585
+ `ApprovalOptions`: `{ customId, description?, severity?, approveLabel?, rejectLabel?, timeoutMs?, pollIntervalMs?, timeoutMeansReject? }`
586
+
587
+ ### `new M91Platform(apiKey, options?)`
588
+
589
+ | Method | |
590
+ |---|---|
591
+ | `verify(): Promise<ApiKeyInfo>` | Name the key and its owner. Alerts nobody. |
592
+ | `inviteRecipient(phone, label?): Promise<Recipient>` | Invite somebody. Idempotent. `label` is your product name. |
593
+ | `removeRecipient(phone): Promise<boolean>` | Stop alerting them. |
594
+ | `alertPerson(phone, title, options?): Promise<AlertResponse>` | Wake one person. |
595
+ | `getAlert(alertId, phone): Promise<AlertDetail \| null>` | Read what they decided. |
596
+ | `closeAlert(phone, { customId? , alertId? }): Promise<boolean>` | Close their alert. |
597
+ | `listChannels(): Promise<ProvisionedChannel[]>` | Every channel this key owns. |
598
+ | `createChannel(name, alertOwner?): Promise<ProvisionedChannel>` | **Not idempotent.** Store the result. |
599
+ | `addMembers(channelId, phones): Promise<InviteResult>` | Up to 500 per call. |
600
+ | `removeMember(channelId, phone): Promise<boolean>` | Take them off the roster. |
601
+
602
+ ### `new M91URLBuilder(sendLink)`
603
+
604
+ | Method | |
605
+ |---|---|
606
+ | `build(title, options?): string` | One alert URL. |
607
+ | `buildMany(alerts): string[]` | One per spec. |
608
+ | `testUrl(): string` | The bare link. Raises nothing. |
609
+
610
+ ### Types
611
+
612
+ `Severity` · `Effect` · `AlertStatus` · `RecipientState` · `ResponseOption` ·
613
+ `Collectable` · `Attachment` · `Decision` · `AlertResponse` · `AlertDetail` · `ChannelInfo` · `Recipient` ·
614
+ `ProvisionedChannel` · `InviteResult` · `ApiKeyInfo`
615
+
616
+ Helpers: `isApproved(decision, approveLabel?)` · `validatePhone(phone)` ·
617
+ `redact(url)` · `buildAlertPayload(title, fields?)`
618
+
619
+ ---
620
+
621
+ ## Running the tests
622
+
623
+ ```bash
624
+ npm test
625
+ ```
626
+
627
+ Compiles with `tsc` and runs the suite on Node's built-in test runner — no test
628
+ framework to install. Every test swaps `fetch`; nothing here makes a network
629
+ call, and nothing wakes anybody.
630
+
631
+ For the SDK against a real socket — a real send, a real poll loop, a real
632
+ timeout — point an example at your own channel. This wakes a phone:
633
+
634
+ ```bash
635
+ export M91_SEND_LINK='https://m91-api.msg91.com/s/<token>'
636
+ npx tsx examples/basic-alert.ts
637
+ ```
638
+
639
+ ---
640
+
641
+ ## Examples
642
+
643
+ The `examples/` directory included with the SDK covers a basic alert, the approval
644
+ gate, a LangChain agent, the LangGraph `interrupt()` pattern, a Vercel AI SDK
645
+ agent, OpenAI Agents, Claude Agent SDK, Google ADK and Mastra agents gated by
646
+ M91, and webhook URLs for Grafana and friends.
647
+
648
+ Full API documentation: **<https://m91-api.msg91.com/docs>**