@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/src/http.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP on native `fetch`, with the retry policy applied. Zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* The rules, which are the interesting part:
|
|
5
|
+
*
|
|
6
|
+
* - **429 is retried exactly once**, after exactly the delay the server asked
|
|
7
|
+
* for. The send limit is 60/minute per link and every 429 carries
|
|
8
|
+
* `Retry-After: 60`. Retrying a second time would be arguing with it.
|
|
9
|
+
* - **5xx is retried with backoff.** Not the caller's fault, and the alert has
|
|
10
|
+
* not been raised, so repeating it cannot double-page anybody.
|
|
11
|
+
* - **4xx is never retried.** Not transient; the next identical request fails
|
|
12
|
+
* identically.
|
|
13
|
+
* - **A network failure is never retried.** A send that timed out may or may not
|
|
14
|
+
* have raised the alert — the response is what was lost, not necessarily the
|
|
15
|
+
* request. Blind retries are how one condition wakes a team four times.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { M91NetworkError, retryAfter, unwrap } from './exceptions.js';
|
|
19
|
+
|
|
20
|
+
export const USER_AGENT = 'm91-node/1.0.0';
|
|
21
|
+
|
|
22
|
+
/** Delays before the 2nd, 3rd and 4th attempt at a 5xx. Four requests total. */
|
|
23
|
+
export const SERVER_BACKOFF = [1000, 2000, 4000] as const;
|
|
24
|
+
|
|
25
|
+
export const RETRYABLE_STATUSES = new Set([500, 502, 503, 504]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A 429 whose `Retry-After` is longer than this is thrown rather than slept
|
|
29
|
+
* through. The server sends 60; anything far past that is a caller sitting
|
|
30
|
+
* blocked in an alerting path, which is worse than an error it can see.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_RETRY_AFTER_MS = 120_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Milliseconds to wait before trying again, or `null` to stop and throw.
|
|
36
|
+
* `attempt` is 1-based and counts requests already made.
|
|
37
|
+
*/
|
|
38
|
+
export function retryDelay(status: number, attempt: number, retryAfterSeconds?: number): number | null {
|
|
39
|
+
if (status === 429) {
|
|
40
|
+
// Once, and only on the first 429. A second means the window has not
|
|
41
|
+
// actually cleared and the caller needs to hear about it.
|
|
42
|
+
if (attempt >= 2) return null;
|
|
43
|
+
const wait = (retryAfterSeconds ?? 60) * 1000;
|
|
44
|
+
return wait > MAX_RETRY_AFTER_MS ? null : Math.max(wait, 0);
|
|
45
|
+
}
|
|
46
|
+
if (RETRYABLE_STATUSES.has(status)) {
|
|
47
|
+
return attempt > SERVER_BACKOFF.length ? null : SERVER_BACKOFF[attempt - 1];
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Matches a send link anywhere — in a bare URL, or buried in a sentence — and
|
|
54
|
+
* takes any query string attached to it. Bounded on whitespace so it stops at
|
|
55
|
+
* the end of the URL rather than eating the rest of the message.
|
|
56
|
+
*/
|
|
57
|
+
const SEND_URL_RE = /(\/s\/)[^\s/?#]+(\?[^\s]*)?/g;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Replace a send link's token with `***`, wherever it appears.
|
|
61
|
+
*
|
|
62
|
+
* **The token is the credential and it lives in the path**, so a URL is as
|
|
63
|
+
* sensitive as a password and must never reach a log, an error message or a
|
|
64
|
+
* stack trace intact. The server holds itself to the same rule — its error
|
|
65
|
+
* handler reports the route *pattern*, never `req.path`.
|
|
66
|
+
*
|
|
67
|
+
* This takes free text, not only a URL, and that is the point. Redacting only
|
|
68
|
+
* the URL the SDK interpolates is not enough: a fetch failure can carry the URL
|
|
69
|
+
* in its own message, so a carefully-redacted sentence would arrive with the
|
|
70
|
+
* raw token attached to it by the runtime.
|
|
71
|
+
*
|
|
72
|
+
* Query strings go too: a GET-form send carries the alert title, and titles
|
|
73
|
+
* routinely name customers and hosts.
|
|
74
|
+
*/
|
|
75
|
+
export function redact(text: string): string {
|
|
76
|
+
if (typeof text !== 'string') return text;
|
|
77
|
+
return text.replace(SEND_URL_RE, '$1***');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RequestOptions {
|
|
81
|
+
method: string;
|
|
82
|
+
url: string;
|
|
83
|
+
body?: unknown;
|
|
84
|
+
query?: Record<string, string>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface TransportOptions {
|
|
88
|
+
/** Whole-request budget in milliseconds. Default 15s (5s connect + 10s read). */
|
|
89
|
+
timeoutMs?: number;
|
|
90
|
+
headers?: Record<string, string>;
|
|
91
|
+
/** Swappable so tests need no network and no mocking library. */
|
|
92
|
+
fetchImpl?: typeof fetch;
|
|
93
|
+
sleepImpl?: (ms: number) => Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
97
|
+
|
|
98
|
+
export class Transport {
|
|
99
|
+
private readonly timeoutMs: number;
|
|
100
|
+
private readonly headers: Record<string, string>;
|
|
101
|
+
private readonly fetchImpl: typeof fetch;
|
|
102
|
+
private readonly sleepImpl: (ms: number) => Promise<void>;
|
|
103
|
+
|
|
104
|
+
constructor(options: TransportOptions = {}) {
|
|
105
|
+
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
106
|
+
this.headers = {
|
|
107
|
+
// A GET-form send answers with a rendered HTML page when the caller looks
|
|
108
|
+
// like a browser. That page exists so a person tapping a bookmark sees
|
|
109
|
+
// something other than a wall of JSON — but an SDK is not a person, and
|
|
110
|
+
// has to opt out of it explicitly.
|
|
111
|
+
accept: 'application/json',
|
|
112
|
+
'user-agent': USER_AGENT,
|
|
113
|
+
...options.headers,
|
|
114
|
+
};
|
|
115
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
116
|
+
this.sleepImpl = options.sleepImpl ?? defaultSleep;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async request({ method, url, body, query }: RequestOptions): Promise<unknown> {
|
|
120
|
+
const target = query ? `${url}?${new URLSearchParams(query).toString()}` : url;
|
|
121
|
+
let attempt = 0;
|
|
122
|
+
|
|
123
|
+
for (;;) {
|
|
124
|
+
attempt += 1;
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
127
|
+
|
|
128
|
+
let status: number;
|
|
129
|
+
let headers: Headers;
|
|
130
|
+
let parsed: unknown;
|
|
131
|
+
try {
|
|
132
|
+
const response = await this.fetchImpl(target, {
|
|
133
|
+
method,
|
|
134
|
+
headers: body === undefined
|
|
135
|
+
? this.headers
|
|
136
|
+
: { ...this.headers, 'content-type': 'application/json' },
|
|
137
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
138
|
+
signal: controller.signal,
|
|
139
|
+
});
|
|
140
|
+
status = response.status;
|
|
141
|
+
headers = response.headers;
|
|
142
|
+
parsed = await readBody(response);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// Redacted AFTER composing, not before: a fetch failure can name the
|
|
145
|
+
// URL in its own message, so masking only our half still ships the token.
|
|
146
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
147
|
+
throw new M91NetworkError(redact(`Could not reach M91 at ${target}: ${reason}`));
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const delay = retryDelay(status, attempt, retryAfter(headers));
|
|
153
|
+
if (delay === null) return unwrap(status, parsed, headers);
|
|
154
|
+
await this.sleepImpl(delay);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The parsed envelope, or something `unwrap` can still turn into an error.
|
|
161
|
+
*
|
|
162
|
+
* A non-JSON body here means a proxy, a load balancer or an HTML error page
|
|
163
|
+
* answered instead of M91. Wrapping the text in an envelope-shaped object keeps
|
|
164
|
+
* all the error construction in one place rather than adding a second path.
|
|
165
|
+
*/
|
|
166
|
+
async function readBody(response: Response): Promise<unknown> {
|
|
167
|
+
const text = await response.text();
|
|
168
|
+
if (!text) return { error: { message: `HTTP ${response.status} with an empty body` } };
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(text);
|
|
171
|
+
} catch {
|
|
172
|
+
return { error: { message: text.slice(0, 300) } };
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M91 — wake a human when software cannot carry on alone.
|
|
3
|
+
*
|
|
4
|
+
* An alert rings every phone in a channel at full volume, through silent mode
|
|
5
|
+
* and Do Not Disturb, and keeps ringing until somebody answers. HIGH and
|
|
6
|
+
* CRITICAL place a voice call if 45 seconds pass with no answer. Whoever takes
|
|
7
|
+
* it on is recorded, and it clears for everyone else.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { M91Client } from '@msg91comm/m91';
|
|
11
|
+
*
|
|
12
|
+
* const m91 = new M91Client(process.env.M91_SEND_LINK!);
|
|
13
|
+
* await m91.verify(); // raises nothing; names the channel
|
|
14
|
+
* await m91.send('Payments API is down', { severity: 'CRITICAL', customId: 'payments-down' });
|
|
15
|
+
* await m91.close({ customId: 'payments-down' });
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* And the reason an agent wants it — a question that blocks until a person
|
|
19
|
+
* answers on their phone:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* if (await m91.requestApproval('Refund 48,000 on ORD-40122?', { customId: 'refund-ORD-40122' })) {
|
|
23
|
+
* await processRefund();
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Everything here is a convenience over one URL. The send link works from a
|
|
28
|
+
* browser bar, a cron `curl` or an `<img>` tag with no client at all, and
|
|
29
|
+
* `M91URLBuilder` is for exactly those.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export { M91Client } from './client.js';
|
|
33
|
+
export type { ApprovalOptions, ClientOptions, SendOptions, WaitOptions } from './client.js';
|
|
34
|
+
|
|
35
|
+
export { M91Platform, DEFAULT_BASE_URL } from './platform.js';
|
|
36
|
+
export type { PlatformOptions } from './platform.js';
|
|
37
|
+
|
|
38
|
+
export { M91URLBuilder, normalizeSendLink } from './url-builder.js';
|
|
39
|
+
export type { BuildManySpec, BuildUrlOptions } from './url-builder.js';
|
|
40
|
+
|
|
41
|
+
export { redact } from './http.js';
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
M91Error,
|
|
45
|
+
M91ValidationError,
|
|
46
|
+
M91AuthError,
|
|
47
|
+
M91ForbiddenError,
|
|
48
|
+
M91NotFoundError,
|
|
49
|
+
M91ChannelEmptyError,
|
|
50
|
+
M91RateLimitError,
|
|
51
|
+
M91ServerError,
|
|
52
|
+
M91NetworkError,
|
|
53
|
+
M91DeliveryError,
|
|
54
|
+
M91TimeoutError,
|
|
55
|
+
} from './exceptions.js';
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
SEVERITIES,
|
|
59
|
+
EFFECTS,
|
|
60
|
+
ESCALATING_SEVERITIES,
|
|
61
|
+
isApproved,
|
|
62
|
+
validatePhone,
|
|
63
|
+
buildAlertPayload,
|
|
64
|
+
} from './models.js';
|
|
65
|
+
|
|
66
|
+
export type {
|
|
67
|
+
AlertDetail,
|
|
68
|
+
AlertResponse,
|
|
69
|
+
AlertStatus,
|
|
70
|
+
ApiKeyInfo,
|
|
71
|
+
ChannelInfo,
|
|
72
|
+
Attachment,
|
|
73
|
+
Decision,
|
|
74
|
+
Response,
|
|
75
|
+
Effect,
|
|
76
|
+
InviteResult,
|
|
77
|
+
ProvisionedChannel,
|
|
78
|
+
Recipient,
|
|
79
|
+
RecipientState,
|
|
80
|
+
ResponseOption,
|
|
81
|
+
Severity,
|
|
82
|
+
} from './models.js';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Kept in step with package.json by `tests/version.test.ts`, not by hand.
|
|
86
|
+
*
|
|
87
|
+
* ESM has no portable way to read package.json at runtime, so this number is
|
|
88
|
+
* written twice — and it drifted, reporting 1.0.0 while the package shipped as
|
|
89
|
+
* 1.1.0. A test is what makes two copies safe.
|
|
90
|
+
*/
|
|
91
|
+
export const VERSION = '1.2.0';
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`): M91 as the permission
|
|
3
|
+
* prompt, and M91 as tools.
|
|
4
|
+
*
|
|
5
|
+
* **Permission prompt.** `createM91CanUseTool` is a `canUseTool` callback: the
|
|
6
|
+
* tools you name ring a phone and run only if a person approves. Everything
|
|
7
|
+
* else is allowed through untouched.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
11
|
+
* import { M91Client } from '@msg91comm/m91';
|
|
12
|
+
* import { createM91CanUseTool } from '@msg91comm/m91/integrations/claude-agent-sdk';
|
|
13
|
+
*
|
|
14
|
+
* const client = new M91Client(process.env.M91_SEND_LINK!);
|
|
15
|
+
*
|
|
16
|
+
* for await (const message of query({
|
|
17
|
+
* prompt: 'Clean up the stale feature branches and deploy main.',
|
|
18
|
+
* options: { canUseTool: createM91CanUseTool(client, { tools: ['Bash', 'Write'] }) },
|
|
19
|
+
* })) {
|
|
20
|
+
* if (message.type === 'result') console.log(message);
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* `canUseTool` only runs when the SDK would otherwise ask. To gate a tool
|
|
25
|
+
* whatever the permission mode (including `bypassPermissions`), use the
|
|
26
|
+
* `PreToolUse` hook from `createM91PreToolUseHook` instead.
|
|
27
|
+
*
|
|
28
|
+
* **Tools.** `createM91McpServer` exposes `m91_request_approval` and
|
|
29
|
+
* `m91_send_alert` as an in-process MCP server, for when the agent itself
|
|
30
|
+
* should decide to ask or to wake somebody.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type {
|
|
34
|
+
CanUseTool,
|
|
35
|
+
HookCallbackMatcher,
|
|
36
|
+
McpSdkServerConfigWithInstance,
|
|
37
|
+
PermissionResult,
|
|
38
|
+
} from '@anthropic-ai/claude-agent-sdk';
|
|
39
|
+
|
|
40
|
+
import type { M91Client } from '../client.js';
|
|
41
|
+
import {
|
|
42
|
+
ALERT_DESCRIPTION,
|
|
43
|
+
ALERT_TOOL_NAME,
|
|
44
|
+
APPROVAL_DESCRIPTION,
|
|
45
|
+
APPROVAL_TOOL_NAME,
|
|
46
|
+
type AlertArgs,
|
|
47
|
+
type AlertDefaults,
|
|
48
|
+
type ApprovalArgs,
|
|
49
|
+
type ApprovalDefaults,
|
|
50
|
+
type GateOptions,
|
|
51
|
+
decideToolCall,
|
|
52
|
+
needsHuman,
|
|
53
|
+
runAlert,
|
|
54
|
+
runApproval,
|
|
55
|
+
} from './specs.js';
|
|
56
|
+
import { alertShape, approvalShape, loadZod } from './zod-shapes.js';
|
|
57
|
+
|
|
58
|
+
/** Tool names, or a predicate. Required: an agent that rings for every `Read` gets muted. */
|
|
59
|
+
type ClaudeGateOptions = GateOptions & { tools: NonNullable<GateOptions['tools']> };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A `canUseTool` callback that asks a person on their phone before the tools
|
|
63
|
+
* in `options.tools` run.
|
|
64
|
+
*/
|
|
65
|
+
export function createM91CanUseTool(client: M91Client, options: ClaudeGateOptions): CanUseTool {
|
|
66
|
+
return async (toolName, input, { toolUseID }): Promise<PermissionResult> => {
|
|
67
|
+
if (!(await needsHuman(options, toolName, input))) {
|
|
68
|
+
return { behavior: 'allow', updatedInput: input };
|
|
69
|
+
}
|
|
70
|
+
const decision = await decideToolCall(client, { toolName, input, callId: toolUseID }, options);
|
|
71
|
+
return decision.approved
|
|
72
|
+
? { behavior: 'allow', updatedInput: input }
|
|
73
|
+
: { behavior: 'deny', message: decision.reason };
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A `PreToolUse` hook matcher that asks a person before the tools in
|
|
79
|
+
* `options.tools` run, in every permission mode. Pass it as
|
|
80
|
+
* `options.hooks.PreToolUse`.
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* query({ prompt, options: { hooks: { PreToolUse: createM91PreToolUseHook(client, { tools: ['Bash'] }) } } })
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function createM91PreToolUseHook(
|
|
87
|
+
client: M91Client,
|
|
88
|
+
options: ClaudeGateOptions & { timeoutSeconds?: number },
|
|
89
|
+
): HookCallbackMatcher[] {
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
// The hook must outlive the wait for a human, or the SDK gives up first.
|
|
93
|
+
timeout: (options.timeoutSeconds ?? 300) + 60,
|
|
94
|
+
hooks: [
|
|
95
|
+
async (input) => {
|
|
96
|
+
if (input.hook_event_name !== 'PreToolUse') return {};
|
|
97
|
+
if (!(await needsHuman(options, input.tool_name, input.tool_input))) return {};
|
|
98
|
+
const decision = await decideToolCall(
|
|
99
|
+
client,
|
|
100
|
+
{ toolName: input.tool_name, input: input.tool_input, callId: input.tool_use_id },
|
|
101
|
+
options,
|
|
102
|
+
);
|
|
103
|
+
return {
|
|
104
|
+
hookSpecificOutput: {
|
|
105
|
+
hookEventName: 'PreToolUse',
|
|
106
|
+
permissionDecision: decision.approved ? 'allow' : 'deny',
|
|
107
|
+
permissionDecisionReason: decision.reason,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface ClaudeSdkModule {
|
|
117
|
+
tool: (name: string, description: string, shape: unknown, handler: unknown) => unknown;
|
|
118
|
+
createSdkMcpServer: (options: { name: string; version?: string; tools: unknown[] }) => unknown;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function claudeSdk(): Promise<ClaudeSdkModule> {
|
|
122
|
+
try {
|
|
123
|
+
const specifier = '@anthropic-ai/claude-agent-sdk';
|
|
124
|
+
return (await import(specifier)) as unknown as ClaudeSdkModule;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'The Claude Agent SDK integration needs @anthropic-ai/claude-agent-sdk. Install it with: ' +
|
|
128
|
+
'npm install @anthropic-ai/claude-agent-sdk',
|
|
129
|
+
{ cause: error },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] });
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `m91_request_approval` and `m91_send_alert` as an in-process MCP server.
|
|
138
|
+
* Register it under `mcpServers.m91`; the tools appear to Claude as
|
|
139
|
+
* `mcp__m91__m91_request_approval` and `mcp__m91__m91_send_alert`.
|
|
140
|
+
*/
|
|
141
|
+
export async function createM91McpServer(
|
|
142
|
+
client: M91Client,
|
|
143
|
+
defaults: ApprovalDefaults & AlertDefaults = {},
|
|
144
|
+
): Promise<McpSdkServerConfigWithInstance> {
|
|
145
|
+
const { tool, createSdkMcpServer } = await claudeSdk();
|
|
146
|
+
const z = await loadZod('Claude Agent SDK');
|
|
147
|
+
return createSdkMcpServer({
|
|
148
|
+
name: 'm91',
|
|
149
|
+
tools: [
|
|
150
|
+
tool(APPROVAL_TOOL_NAME, APPROVAL_DESCRIPTION, approvalShape(z), async (args: ApprovalArgs) =>
|
|
151
|
+
text(await runApproval(client, args, defaults)),
|
|
152
|
+
),
|
|
153
|
+
tool(ALERT_TOOL_NAME, ALERT_DESCRIPTION, alertShape(z), async (args: AlertArgs) =>
|
|
154
|
+
text(await runAlert(client, args, defaults)),
|
|
155
|
+
),
|
|
156
|
+
],
|
|
157
|
+
}) as McpSdkServerConfigWithInstance;
|
|
158
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Agent Development Kit for TypeScript (`@google/adk`): M91 as a
|
|
3
|
+
* before-tool gate, and M91 as tools.
|
|
4
|
+
*
|
|
5
|
+
* **Gate.** `createM91BeforeToolCallback` goes in an `LlmAgent`'s
|
|
6
|
+
* `beforeToolCallback`. Calls to the tools you name ring a phone first; if the
|
|
7
|
+
* person rejects (or does not answer), the tool does not run and the agent
|
|
8
|
+
* gets the reason back as the tool's result.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { LlmAgent } from '@google/adk';
|
|
12
|
+
* import { M91Client } from '@msg91comm/m91';
|
|
13
|
+
* import { createM91BeforeToolCallback, createM91Tools } from '@msg91comm/m91/integrations/google-adk';
|
|
14
|
+
*
|
|
15
|
+
* const client = new M91Client(process.env.M91_SEND_LINK!);
|
|
16
|
+
*
|
|
17
|
+
* const agent = new LlmAgent({
|
|
18
|
+
* name: 'support_agent',
|
|
19
|
+
* model: 'gemini-2.5-flash',
|
|
20
|
+
* instruction: 'Resolve refund requests.',
|
|
21
|
+
* tools: [issueRefund, ...(await createM91Tools(client))],
|
|
22
|
+
* beforeToolCallback: createM91BeforeToolCallback(client, { tools: ['issue_refund'] }),
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The gate waits for the answer in-process. For approvals that take hours,
|
|
27
|
+
* use ADK's own Tool Confirmation and resume the session when M91's answer
|
|
28
|
+
* arrives.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { BaseTool, FunctionTool, SingleBeforeToolCallback } from '@google/adk';
|
|
32
|
+
|
|
33
|
+
import type { M91Client } from '../client.js';
|
|
34
|
+
import {
|
|
35
|
+
ALERT_DESCRIPTION,
|
|
36
|
+
ALERT_TOOL_NAME,
|
|
37
|
+
APPROVAL_DESCRIPTION,
|
|
38
|
+
APPROVAL_TOOL_NAME,
|
|
39
|
+
type AlertArgs,
|
|
40
|
+
type AlertDefaults,
|
|
41
|
+
type ApprovalArgs,
|
|
42
|
+
type ApprovalDefaults,
|
|
43
|
+
type GateOptions,
|
|
44
|
+
decideToolCall,
|
|
45
|
+
needsHuman,
|
|
46
|
+
runAlert,
|
|
47
|
+
runApproval,
|
|
48
|
+
} from './specs.js';
|
|
49
|
+
import { alertShape, approvalShape, loadZod } from './zod-shapes.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A `beforeToolCallback` that asks a person on their phone before the tools
|
|
53
|
+
* in `options.tools` run. Returning a result object skips the tool, which is
|
|
54
|
+
* how a rejection reaches the model.
|
|
55
|
+
*/
|
|
56
|
+
export function createM91BeforeToolCallback(
|
|
57
|
+
client: M91Client,
|
|
58
|
+
options: GateOptions & { tools: NonNullable<GateOptions['tools']> },
|
|
59
|
+
): SingleBeforeToolCallback {
|
|
60
|
+
return async ({ tool, args, context }) => {
|
|
61
|
+
const toolName = tool.name;
|
|
62
|
+
if (!(await needsHuman(options, toolName, args))) return undefined;
|
|
63
|
+
const callId = context.functionCallId ?? `${context.invocationId}-${toolName}`;
|
|
64
|
+
const decision = await decideToolCall(client, { toolName, input: args, callId }, options);
|
|
65
|
+
if (decision.approved) return undefined;
|
|
66
|
+
return { status: 'rejected', error: decision.reason };
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface AdkModule {
|
|
71
|
+
FunctionTool: new (options: unknown) => FunctionTool<any>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function adkModule(): Promise<AdkModule> {
|
|
75
|
+
try {
|
|
76
|
+
const specifier = '@google/adk';
|
|
77
|
+
return (await import(specifier)) as unknown as AdkModule;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'The Google ADK integration needs @google/adk. Install it with: npm install @google/adk',
|
|
81
|
+
{ cause: error },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The blocking approval tool, `m91_request_approval`, as an ADK `FunctionTool`. */
|
|
87
|
+
export async function createM91ApprovalTool(
|
|
88
|
+
client: M91Client,
|
|
89
|
+
defaults: ApprovalDefaults = {},
|
|
90
|
+
): Promise<BaseTool> {
|
|
91
|
+
const { FunctionTool } = await adkModule();
|
|
92
|
+
const z = await loadZod('Google ADK');
|
|
93
|
+
return new FunctionTool({
|
|
94
|
+
name: APPROVAL_TOOL_NAME,
|
|
95
|
+
description: APPROVAL_DESCRIPTION,
|
|
96
|
+
parameters: z.object(approvalShape(z)),
|
|
97
|
+
execute: async (args: ApprovalArgs) => ({ result: await runApproval(client, args, defaults) }),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The fire-and-forget alert tool, `m91_send_alert`, as an ADK `FunctionTool`. */
|
|
102
|
+
export async function createM91AlertTool(
|
|
103
|
+
client: M91Client,
|
|
104
|
+
defaults: AlertDefaults = {},
|
|
105
|
+
): Promise<BaseTool> {
|
|
106
|
+
const { FunctionTool } = await adkModule();
|
|
107
|
+
const z = await loadZod('Google ADK');
|
|
108
|
+
return new FunctionTool({
|
|
109
|
+
name: ALERT_TOOL_NAME,
|
|
110
|
+
description: ALERT_DESCRIPTION,
|
|
111
|
+
parameters: z.object(alertShape(z)),
|
|
112
|
+
execute: async (args: AlertArgs) => ({ result: await runAlert(client, args, defaults) }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Both tools, for an agent's `tools` array. */
|
|
117
|
+
export async function createM91Tools(
|
|
118
|
+
client: M91Client,
|
|
119
|
+
defaults: ApprovalDefaults & AlertDefaults = {},
|
|
120
|
+
): Promise<BaseTool[]> {
|
|
121
|
+
return [await createM91ApprovalTool(client, defaults), await createM91AlertTool(client, defaults)];
|
|
122
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework adapters, re-exported.
|
|
3
|
+
*
|
|
4
|
+
* Each one imports its framework lazily, so `npm install @msg91comm/m91` pulls in none of
|
|
5
|
+
* them and using one you have not installed says which package to install
|
|
6
|
+
* rather than failing with a bare module-not-found from three frames down.
|
|
7
|
+
*
|
|
8
|
+
* Importing a specific adapter directly is usually better —
|
|
9
|
+
* `@msg91comm/m91/integrations/langchain` — because it pulls in only what you use.
|
|
10
|
+
*
|
|
11
|
+
* Several adapters export `createM91ApprovalTool`, so
|
|
12
|
+
* they are re-exported under namespaces rather than flattened: two functions of
|
|
13
|
+
* the same name doing the same job for different frameworks is exactly the pair
|
|
14
|
+
* that would silently resolve to the wrong one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export * as langchain from './langchain.js';
|
|
18
|
+
export * as langgraph from './langgraph.js';
|
|
19
|
+
export * as vercelAi from './vercel-ai.js';
|
|
20
|
+
export * as openaiAgents from './openai-agents.js';
|
|
21
|
+
export * as claudeAgentSdk from './claude-agent-sdk.js';
|
|
22
|
+
export * as googleAdk from './google-adk.js';
|
|
23
|
+
export * as mastra from './mastra.js';
|
|
24
|
+
|
|
25
|
+
export { M91InterruptNotifier, createM91ApprovalNode } from './langgraph.js';
|
|
26
|
+
export { APPROVED, REJECTED, alertResult, decideToolCall, errorResult } from './specs.js';
|
|
27
|
+
export type { AlertArgs, ApprovalArgs, GateDecision, GateOptions, GatedCall } from './specs.js';
|