@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.
- package/CHANGELOG.md +94 -0
- package/LICENSE +21 -0
- package/README.md +647 -2
- package/dist/src/client.d.ts +165 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +221 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/exceptions.d.ts +114 -0
- package/dist/src/exceptions.d.ts.map +1 -0
- package/dist/src/exceptions.js +199 -0
- package/dist/src/exceptions.js.map +1 -0
- package/dist/src/http.d.ts +71 -0
- package/dist/src/http.d.ts.map +1 -0
- package/dist/src/http.js +150 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/index.d.ts +49 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +45 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/integrations/claude-agent-sdk.d.ts +63 -0
- package/dist/src/integrations/claude-agent-sdk.d.ts.map +1 -0
- package/dist/src/integrations/claude-agent-sdk.js +109 -0
- package/dist/src/integrations/claude-agent-sdk.js.map +1 -0
- package/dist/src/integrations/google-adk.d.ts +47 -0
- package/dist/src/integrations/google-adk.d.ts.map +1 -0
- package/dist/src/integrations/google-adk.js +84 -0
- package/dist/src/integrations/google-adk.js.map +1 -0
- package/dist/src/integrations/index.d.ts +26 -0
- package/dist/src/integrations/index.d.ts.map +1 -0
- package/dist/src/integrations/index.js +25 -0
- package/dist/src/integrations/index.js.map +1 -0
- package/dist/src/integrations/langchain.d.ts +94 -0
- package/dist/src/integrations/langchain.d.ts.map +1 -0
- package/dist/src/integrations/langchain.js +129 -0
- package/dist/src/integrations/langchain.js.map +1 -0
- package/dist/src/integrations/langgraph.d.ts +79 -0
- package/dist/src/integrations/langgraph.d.ts.map +1 -0
- package/dist/src/integrations/langgraph.js +114 -0
- package/dist/src/integrations/langgraph.js.map +1 -0
- package/dist/src/integrations/mastra.d.ts +75 -0
- package/dist/src/integrations/mastra.d.ts.map +1 -0
- package/dist/src/integrations/mastra.js +109 -0
- package/dist/src/integrations/mastra.js.map +1 -0
- package/dist/src/integrations/openai-agents.d.ts +61 -0
- package/dist/src/integrations/openai-agents.d.ts.map +1 -0
- package/dist/src/integrations/openai-agents.js +127 -0
- package/dist/src/integrations/openai-agents.js.map +1 -0
- package/dist/src/integrations/specs.d.ts +173 -0
- package/dist/src/integrations/specs.d.ts.map +1 -0
- package/dist/src/integrations/specs.js +217 -0
- package/dist/src/integrations/specs.js.map +1 -0
- package/dist/src/integrations/vercel-ai.d.ts +74 -0
- package/dist/src/integrations/vercel-ai.d.ts.map +1 -0
- package/dist/src/integrations/vercel-ai.js +102 -0
- package/dist/src/integrations/vercel-ai.js.map +1 -0
- package/dist/src/integrations/zod-shapes.d.ts +19 -0
- package/dist/src/integrations/zod-shapes.d.ts.map +1 -0
- package/dist/src/integrations/zod-shapes.js +44 -0
- package/dist/src/integrations/zod-shapes.js.map +1 -0
- package/dist/src/models.d.ts +274 -0
- package/dist/src/models.d.ts.map +1 -0
- package/dist/src/models.js +383 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/platform.d.ts +176 -0
- package/dist/src/platform.d.ts.map +1 -0
- package/dist/src/platform.js +286 -0
- package/dist/src/platform.js.map +1 -0
- package/dist/src/url-builder.d.ts +65 -0
- package/dist/src/url-builder.d.ts.map +1 -0
- package/dist/src/url-builder.js +167 -0
- package/dist/src/url-builder.js.map +1 -0
- package/examples/approval-gate.ts +35 -0
- package/examples/basic-alert.ts +53 -0
- package/examples/claude-agent-sdk.ts +23 -0
- package/examples/deep-agents.ts +31 -0
- package/examples/google-adk.ts +39 -0
- package/examples/langchain-agent.ts +65 -0
- package/examples/langgraph-hitl.ts +70 -0
- package/examples/mastra.ts +41 -0
- package/examples/openai-agents.ts +33 -0
- package/examples/vercel-ai-agent.ts +34 -0
- package/examples/webhook-urls.ts +44 -0
- package/package.json +135 -4
- package/src/client.ts +324 -0
- package/src/exceptions.ts +229 -0
- package/src/http.ts +174 -0
- package/src/index.ts +91 -0
- package/src/integrations/claude-agent-sdk.ts +158 -0
- package/src/integrations/google-adk.ts +122 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/langchain.ts +207 -0
- package/src/integrations/langgraph.ts +142 -0
- package/src/integrations/mastra.ts +178 -0
- package/src/integrations/openai-agents.ts +173 -0
- package/src/integrations/specs.ts +328 -0
- package/src/integrations/vercel-ai.ts +151 -0
- package/src/integrations/zod-shapes.ts +55 -0
- package/src/models.ts +660 -0
- package/src/platform.ts +343 -0
- package/src/url-builder.ts +225 -0
package/README.md
CHANGED
|
@@ -1,3 +1,648 @@
|
|
|
1
|
-
#
|
|
1
|
+
# m91
|
|
2
2
|
|
|
3
|
-
|
|
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>**
|