@blockrun/franklin 3.20.1 → 3.20.2
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/dist/agent/context.js +1 -1
- package/dist/tools/blockrun.js +3 -3
- package/dist/tools/index.js +15 -1
- package/dist/tools/phone.d.ts +20 -0
- package/dist/tools/phone.js +284 -0
- package/dist/tools/voice.d.ts +19 -0
- package/dist/tools/voice.js +273 -0
- package/package.json +1 -1
package/dist/agent/context.js
CHANGED
|
@@ -307,7 +307,7 @@ On-chain affiliate (20 bps in sell-token, force-set server-side) flows to BlockR
|
|
|
307
307
|
- \`/v1/surf/fund/{detail,portfolio,ranking}\` — VC fund profiles, portfolios, ranking.
|
|
308
308
|
- \`/v1/surf/project/{detail,defi/metrics,defi/ranking}\` — project profiles + DeFi protocol metrics.
|
|
309
309
|
|
|
310
|
-
For Surf workflows, prefer the bundled skills (\`/surf-market\`, \`/surf-chain\`, \`/surf-social\`) — they document which endpoint to pick for which question and the cost trade-off. Skipped (use the dedicated tools instead): \`/v1/surf/prediction-market/*\` (use \`PredictionMarket\`), \`/v1/surf/search/*\` (use \`ExaSearch\`), \`/v1/surf/web/*\` (use \`BrowserX\`). \`/v1/surf/chat/completions
|
|
310
|
+
For Surf workflows, prefer the bundled skills (\`/surf-market\`, \`/surf-chain\`, \`/surf-social\`) — they document which endpoint to pick for which question and the cost trade-off. Skipped (use the dedicated tools instead): \`/v1/surf/prediction-market/*\` (use \`PredictionMarket\`), \`/v1/surf/search/*\` (use \`ExaSearch\`), \`/v1/surf/web/*\` (use \`BrowserX\`). The Surf chat surface (\`/v1/surf/chat/completions\`, surf-1.5) is **not currently exposed** by the BlockRun gateway — removed from the registry pending an upstream redesign around per-token billing. Do not attempt to call it; use the data endpoints above for crypto context, or any of the standard LLMs on \`/v1/chat/completions\` for general chat.
|
|
311
311
|
|
|
312
312
|
**Generic gateway primitive**: \`BlockRun({ path, method, params, body })\` is a single capability that signs x402 and forwards to ANY path under \`/api\`. Use it for Surf endpoints (above) and any future BlockRun partner that doesn't have a dedicated capability yet. Always specify the exact path; the primitive will not guess.
|
|
313
313
|
|
package/dist/tools/blockrun.js
CHANGED
|
@@ -174,10 +174,10 @@ export const blockrunCapability = {
|
|
|
174
174
|
spec: {
|
|
175
175
|
name: 'BlockRun',
|
|
176
176
|
description: 'Call any BlockRun gateway endpoint. Signs an x402 USDC payment from the user wallet, retries on HTTP 402, and returns the response. ' +
|
|
177
|
-
'Use this for crypto data (Surf — markets, on-chain, social
|
|
178
|
-
'
|
|
177
|
+
'Use this for crypto data (Surf — markets, on-chain, social), AI inference (chat / image / video / music), prediction markets, DeFi data, and any other API exposed under https://blockrun.ai/marketplace. ' +
|
|
178
|
+
'For phone and voice, prefer the typed tools (ListPhoneNumbers, BuyPhoneNumber, RenewPhoneNumber, ReleasePhoneNumber, PhoneLookup, PhoneFraudCheck, VoiceCall, VoiceStatus) — they spell out cost, required fields, and the buy-number-first requirement. ' +
|
|
179
179
|
'The path must start with "/v1/" or "/.well-known/". ' +
|
|
180
|
-
'Bundled skills like /surf-market, /surf-chain, /surf-social
|
|
180
|
+
'Bundled skills like /surf-market, /surf-chain, /surf-social document which endpoints to call for common workflows — read those when you are unsure which path serves the user\'s question. ' +
|
|
181
181
|
'Cost is wallet-charged automatically; the response includes the actual USD paid.',
|
|
182
182
|
input_schema: {
|
|
183
183
|
type: 'object',
|
package/dist/tools/index.js
CHANGED
|
@@ -33,6 +33,8 @@ import { defiLlamaProtocolsCapability, defiLlamaProtocolCapability, defiLlamaCha
|
|
|
33
33
|
import { predictionMarketCapability } from './prediction.js';
|
|
34
34
|
import { modalCapabilities } from './modal.js';
|
|
35
35
|
import { blockrunCapability } from './blockrun.js';
|
|
36
|
+
import { listPhoneNumbersCapability, buyPhoneNumberCapability, renewPhoneNumberCapability, releasePhoneNumberCapability, phoneLookupCapability, phoneFraudCheckCapability, } from './phone.js';
|
|
37
|
+
import { voiceCallCapability, voiceStatusCapability } from './voice.js';
|
|
36
38
|
import { createTradingCapabilities } from './trading-execute.js';
|
|
37
39
|
import { Portfolio } from '../trading/portfolio.js';
|
|
38
40
|
import { RiskEngine } from '../trading/risk.js';
|
|
@@ -164,7 +166,19 @@ export const allCapabilities = [
|
|
|
164
166
|
defiLlamaYieldsCapability,
|
|
165
167
|
defiLlamaPriceCapability,
|
|
166
168
|
predictionMarketCapability, // Polymarket / Kalshi / matching / smart money via Predexon
|
|
167
|
-
blockrunCapability, // Generic x402-paid gateway primitive — Surf,
|
|
169
|
+
blockrunCapability, // Generic x402-paid gateway primitive — Surf, future partners (see /surf-* skills)
|
|
170
|
+
// Phone & Voice — typed surface so the agent pattern-matches on the user
|
|
171
|
+
// intent ("buy a number", "make a call") without needing to consult the
|
|
172
|
+
// BlockRun primitive or the .well-known/x402 manifest. All wrap the same
|
|
173
|
+
// /v1/phone/* and /v1/voice/* endpoints under the hood.
|
|
174
|
+
listPhoneNumbersCapability, // ListPhoneNumbers — $0.001
|
|
175
|
+
buyPhoneNumberCapability, // BuyPhoneNumber — $5 / 30 days
|
|
176
|
+
renewPhoneNumberCapability, // RenewPhoneNumber — $5 / 30 days
|
|
177
|
+
releasePhoneNumberCapability, // ReleasePhoneNumber — free
|
|
178
|
+
phoneLookupCapability, // PhoneLookup — $0.01
|
|
179
|
+
phoneFraudCheckCapability, // PhoneFraudCheck — $0.05
|
|
180
|
+
voiceCallCapability, // VoiceCall — $0.54 / call (Bland.ai)
|
|
181
|
+
voiceStatusCapability, // VoiceStatus — free (poll)
|
|
168
182
|
// Modal GPU sandbox tools — registered but hidden by default (not in
|
|
169
183
|
// CORE_TOOL_NAMES). Agent must `ActivateTool({names:["ModalCreate",...]})`
|
|
170
184
|
// before they appear in its tool inventory. High-cost ($0.40/H100 create)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phone number management — buy / list / renew / release / lookup wallet-
|
|
3
|
+
* owned phone numbers via the BlockRun gateway `/v1/phone/*` endpoints.
|
|
4
|
+
*
|
|
5
|
+
* Each lifecycle action is its own typed tool (rather than a single generic
|
|
6
|
+
* "phone manager") so the agent's tool-list pattern-matches naturally on the
|
|
7
|
+
* user's intent — "buy me a number" → BuyPhoneNumber, "list my numbers" →
|
|
8
|
+
* ListPhoneNumbers — without needing to consult the BlockRun primitive or
|
|
9
|
+
* the `.well-known/x402` manifest.
|
|
10
|
+
*
|
|
11
|
+
* x402 payment flow mirrors src/tools/exa.ts: a 402 from the gateway triggers
|
|
12
|
+
* a signed USDC transfer (Base or Solana), retry succeeds.
|
|
13
|
+
*/
|
|
14
|
+
import type { CapabilityHandler } from '../agent/types.js';
|
|
15
|
+
export declare const listPhoneNumbersCapability: CapabilityHandler;
|
|
16
|
+
export declare const buyPhoneNumberCapability: CapabilityHandler;
|
|
17
|
+
export declare const renewPhoneNumberCapability: CapabilityHandler;
|
|
18
|
+
export declare const releasePhoneNumberCapability: CapabilityHandler;
|
|
19
|
+
export declare const phoneLookupCapability: CapabilityHandler;
|
|
20
|
+
export declare const phoneFraudCheckCapability: CapabilityHandler;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phone number management — buy / list / renew / release / lookup wallet-
|
|
3
|
+
* owned phone numbers via the BlockRun gateway `/v1/phone/*` endpoints.
|
|
4
|
+
*
|
|
5
|
+
* Each lifecycle action is its own typed tool (rather than a single generic
|
|
6
|
+
* "phone manager") so the agent's tool-list pattern-matches naturally on the
|
|
7
|
+
* user's intent — "buy me a number" → BuyPhoneNumber, "list my numbers" →
|
|
8
|
+
* ListPhoneNumbers — without needing to consult the BlockRun primitive or
|
|
9
|
+
* the `.well-known/x402` manifest.
|
|
10
|
+
*
|
|
11
|
+
* x402 payment flow mirrors src/tools/exa.ts: a 402 from the gateway triggers
|
|
12
|
+
* a signed USDC transfer (Base or Solana), retry succeeds.
|
|
13
|
+
*/
|
|
14
|
+
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
15
|
+
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
16
|
+
import { logger } from '../logger.js';
|
|
17
|
+
const PHONE_TIMEOUT_MS = 30_000;
|
|
18
|
+
// ─── Shared payment flow (POST) ───────────────────────────────────────────
|
|
19
|
+
async function postWithPayment(path, body, ctx) {
|
|
20
|
+
const chain = loadChain();
|
|
21
|
+
const apiUrl = API_URLS[chain];
|
|
22
|
+
const endpoint = `${apiUrl}${path}`;
|
|
23
|
+
const bodyStr = JSON.stringify(body);
|
|
24
|
+
const headers = {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'User-Agent': `franklin/${VERSION}`,
|
|
27
|
+
};
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const timeout = setTimeout(() => controller.abort(), PHONE_TIMEOUT_MS);
|
|
30
|
+
const onAbort = () => controller.abort();
|
|
31
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
32
|
+
try {
|
|
33
|
+
let response = await fetch(endpoint, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
headers,
|
|
37
|
+
body: bodyStr,
|
|
38
|
+
});
|
|
39
|
+
if (response.status === 402) {
|
|
40
|
+
const paymentHeaders = await signPayment(response, chain, endpoint, 'Franklin phone');
|
|
41
|
+
if (!paymentHeaders)
|
|
42
|
+
throw new Error('Payment signing failed — check wallet balance');
|
|
43
|
+
response = await fetch(endpoint, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
headers: { ...headers, ...paymentHeaders },
|
|
47
|
+
body: bodyStr,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const errText = await response.text().catch(() => '');
|
|
52
|
+
throw new Error(`Phone ${path} failed (${response.status}): ${errText.slice(0, 300)}`);
|
|
53
|
+
}
|
|
54
|
+
return (await response.json());
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
clearTimeout(timeout);
|
|
58
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function signPayment(response, chain, endpoint, description) {
|
|
62
|
+
try {
|
|
63
|
+
const paymentHeader = await extractPaymentReq(response);
|
|
64
|
+
if (!paymentHeader)
|
|
65
|
+
return null;
|
|
66
|
+
if (chain === 'solana') {
|
|
67
|
+
const wallet = await getOrCreateSolanaWallet();
|
|
68
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
69
|
+
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
|
|
70
|
+
const secretBytes = await solanaKeyToBytes(wallet.privateKey);
|
|
71
|
+
const feePayer = details.extra?.feePayer || details.recipient;
|
|
72
|
+
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
73
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
74
|
+
resourceDescription: details.resource?.description || description,
|
|
75
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
|
|
76
|
+
extra: details.extra,
|
|
77
|
+
});
|
|
78
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
79
|
+
}
|
|
80
|
+
const wallet = getOrCreateWallet();
|
|
81
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
82
|
+
const details = extractPaymentDetails(paymentRequired);
|
|
83
|
+
const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
|
|
84
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
85
|
+
resourceDescription: details.resource?.description || description,
|
|
86
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
|
|
87
|
+
extra: details.extra,
|
|
88
|
+
});
|
|
89
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
logger.warn(`[franklin] Phone payment error: ${err.message}`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function extractPaymentReq(response) {
|
|
97
|
+
let header = response.headers.get('payment-required');
|
|
98
|
+
if (!header) {
|
|
99
|
+
try {
|
|
100
|
+
const body = (await response.json());
|
|
101
|
+
if (body.x402 || body.accepts)
|
|
102
|
+
header = btoa(JSON.stringify(body));
|
|
103
|
+
}
|
|
104
|
+
catch { /* not JSON */ }
|
|
105
|
+
}
|
|
106
|
+
return header;
|
|
107
|
+
}
|
|
108
|
+
// ─── Tools ─────────────────────────────────────────────────────────────────
|
|
109
|
+
export const listPhoneNumbersCapability = {
|
|
110
|
+
spec: {
|
|
111
|
+
name: 'ListPhoneNumbers',
|
|
112
|
+
description: 'List the phone numbers your wallet currently owns (US/CA, leased 30 days at a time). ' +
|
|
113
|
+
'Use this before any phone-related action to remind the agent what numbers are available. ' +
|
|
114
|
+
'Costs $0.001 USDC. Returns each number with country, area code, expiration timestamp, ' +
|
|
115
|
+
'and current status (active/expiring/expired).',
|
|
116
|
+
input_schema: { type: 'object', properties: {} },
|
|
117
|
+
},
|
|
118
|
+
execute: async (_input, ctx) => {
|
|
119
|
+
try {
|
|
120
|
+
const res = await postWithPayment('/v1/phone/numbers/list', {}, ctx);
|
|
121
|
+
return {
|
|
122
|
+
output: `## Phone numbers (wallet-owned)\n\n` +
|
|
123
|
+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
return { output: `Phone list failed: ${err.message}`, isError: true };
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
export const buyPhoneNumberCapability = {
|
|
132
|
+
spec: {
|
|
133
|
+
name: 'BuyPhoneNumber',
|
|
134
|
+
description: 'Provision a new US or CA phone number for the wallet for 30 days. Costs $5 USDC. ' +
|
|
135
|
+
'Optionally pin a 3-digit area code (best effort). The provisioned number is auto-registered ' +
|
|
136
|
+
'as a valid caller ID for outbound VoiceCall. A wallet can hold multiple numbers; this adds ' +
|
|
137
|
+
'one, never replaces. To pick the country: country="US" (default) or country="CA".',
|
|
138
|
+
input_schema: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
country: { type: 'string', enum: ['US', 'CA'], description: 'Country code (default: US)' },
|
|
142
|
+
area_code: { type: 'string', description: 'Preferred 3-digit area code (best effort)' },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
execute: async (input, ctx) => {
|
|
147
|
+
const body = {};
|
|
148
|
+
if (typeof input.country === 'string')
|
|
149
|
+
body.country = input.country;
|
|
150
|
+
if (typeof input.area_code === 'string')
|
|
151
|
+
body.areaCode = input.area_code;
|
|
152
|
+
try {
|
|
153
|
+
const res = await postWithPayment('/v1/phone/numbers/buy', body, ctx);
|
|
154
|
+
return {
|
|
155
|
+
output: `## Number provisioned ($5 USDC charged)\n\n` +
|
|
156
|
+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
return { output: `Buy failed: ${err.message}`, isError: true };
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
export const renewPhoneNumberCapability = {
|
|
165
|
+
spec: {
|
|
166
|
+
name: 'RenewPhoneNumber',
|
|
167
|
+
description: 'Extend the 30-day lease on a wallet-owned phone number. Costs $5 USDC. Use ListPhoneNumbers ' +
|
|
168
|
+
'first to confirm the number is yours. Released or expired numbers cannot be renewed — buy a ' +
|
|
169
|
+
'new one with BuyPhoneNumber instead.',
|
|
170
|
+
input_schema: {
|
|
171
|
+
type: 'object',
|
|
172
|
+
properties: {
|
|
173
|
+
phone_number: { type: 'string', description: 'E.164 format, e.g. +14155552671' },
|
|
174
|
+
},
|
|
175
|
+
required: ['phone_number'],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
execute: async (input, ctx) => {
|
|
179
|
+
if (typeof input.phone_number !== 'string') {
|
|
180
|
+
return { output: 'phone_number (E.164) required', isError: true };
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const res = await postWithPayment('/v1/phone/numbers/renew', { phoneNumber: input.phone_number }, ctx);
|
|
184
|
+
return {
|
|
185
|
+
output: `## Lease renewed (+30 days, $5 USDC charged)\n\n` +
|
|
186
|
+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
return { output: `Renew failed: ${err.message}`, isError: true };
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
export const releasePhoneNumberCapability = {
|
|
195
|
+
spec: {
|
|
196
|
+
name: 'ReleasePhoneNumber',
|
|
197
|
+
description: 'Release a wallet-owned phone number back to the BlockRun pool before its lease expires. ' +
|
|
198
|
+
'Free. The number is gone after this — it may be picked up by another wallet. Use when you ' +
|
|
199
|
+
"no longer need a test number and want it out of your ListPhoneNumbers result.",
|
|
200
|
+
input_schema: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
properties: {
|
|
203
|
+
phone_number: { type: 'string', description: 'E.164 format, e.g. +14155552671' },
|
|
204
|
+
},
|
|
205
|
+
required: ['phone_number'],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
execute: async (input, ctx) => {
|
|
209
|
+
if (typeof input.phone_number !== 'string') {
|
|
210
|
+
return { output: 'phone_number (E.164) required', isError: true };
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const res = await postWithPayment('/v1/phone/numbers/release', { phoneNumber: input.phone_number }, ctx);
|
|
214
|
+
return {
|
|
215
|
+
output: `## Number released (free)\n\n` +
|
|
216
|
+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
return { output: `Release failed: ${err.message}`, isError: true };
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
export const phoneLookupCapability = {
|
|
225
|
+
spec: {
|
|
226
|
+
name: 'PhoneLookup',
|
|
227
|
+
description: 'Look up carrier and line type information for ANY phone number (does not need to be ' +
|
|
228
|
+
'wallet-owned). Returns carrier name, line type (mobile/landline/voip), country, and ' +
|
|
229
|
+
'portability info. Costs $0.01 USDC. Use to validate a number before texting/calling or ' +
|
|
230
|
+
'to figure out whether a contact number is a real mobile.',
|
|
231
|
+
input_schema: {
|
|
232
|
+
type: 'object',
|
|
233
|
+
properties: {
|
|
234
|
+
phone_number: { type: 'string', description: 'E.164 format, e.g. +14155552671' },
|
|
235
|
+
},
|
|
236
|
+
required: ['phone_number'],
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
execute: async (input, ctx) => {
|
|
240
|
+
if (typeof input.phone_number !== 'string') {
|
|
241
|
+
return { output: 'phone_number (E.164) required', isError: true };
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const res = await postWithPayment('/v1/phone/lookup', { phoneNumber: input.phone_number }, ctx);
|
|
245
|
+
return {
|
|
246
|
+
output: `## Phone lookup ($0.01 USDC charged)\n\n` +
|
|
247
|
+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
return { output: `Lookup failed: ${err.message}`, isError: true };
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
export const phoneFraudCheckCapability = {
|
|
256
|
+
spec: {
|
|
257
|
+
name: 'PhoneFraudCheck',
|
|
258
|
+
description: 'Run a fraud / risk assessment on a phone number — checks SIM swap signals, call forwarding ' +
|
|
259
|
+
'status, and known-spam reputation. Returns a risk score and signal breakdown. Costs $0.05 ' +
|
|
260
|
+
'USDC. Use before sending OTPs or trusting a phone for account recovery.',
|
|
261
|
+
input_schema: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
properties: {
|
|
264
|
+
phone_number: { type: 'string', description: 'E.164 format, e.g. +14155552671' },
|
|
265
|
+
},
|
|
266
|
+
required: ['phone_number'],
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
execute: async (input, ctx) => {
|
|
270
|
+
if (typeof input.phone_number !== 'string') {
|
|
271
|
+
return { output: 'phone_number (E.164) required', isError: true };
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const res = await postWithPayment('/v1/phone/lookup/fraud', { phoneNumber: input.phone_number }, ctx);
|
|
275
|
+
return {
|
|
276
|
+
output: `## Fraud check ($0.05 USDC charged)\n\n` +
|
|
277
|
+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
return { output: `Fraud check failed: ${err.message}`, isError: true };
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound AI voice calls via Bland.ai through the BlockRun `/v1/voice/*`
|
|
3
|
+
* gateway. Two tools:
|
|
4
|
+
*
|
|
5
|
+
* - VoiceCall — POST /v1/voice/call ($0.54 flat, up to 5 min default).
|
|
6
|
+
* Returns call_id immediately; the call runs async upstream.
|
|
7
|
+
* - VoiceStatus — GET /v1/voice/call/{call_id} (free). Polls for transcript
|
|
8
|
+
* + recording + final disposition.
|
|
9
|
+
*
|
|
10
|
+
* Voice calls require a wallet-owned BlockRun phone number as caller ID —
|
|
11
|
+
* use BuyPhoneNumber (or ListPhoneNumbers if one already exists) before
|
|
12
|
+
* calling VoiceCall, otherwise the gateway returns 400 with the buy
|
|
13
|
+
* instructions inline.
|
|
14
|
+
*
|
|
15
|
+
* x402 payment flow mirrors src/tools/exa.ts.
|
|
16
|
+
*/
|
|
17
|
+
import type { CapabilityHandler } from '../agent/types.js';
|
|
18
|
+
export declare const voiceCallCapability: CapabilityHandler;
|
|
19
|
+
export declare const voiceStatusCapability: CapabilityHandler;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound AI voice calls via Bland.ai through the BlockRun `/v1/voice/*`
|
|
3
|
+
* gateway. Two tools:
|
|
4
|
+
*
|
|
5
|
+
* - VoiceCall — POST /v1/voice/call ($0.54 flat, up to 5 min default).
|
|
6
|
+
* Returns call_id immediately; the call runs async upstream.
|
|
7
|
+
* - VoiceStatus — GET /v1/voice/call/{call_id} (free). Polls for transcript
|
|
8
|
+
* + recording + final disposition.
|
|
9
|
+
*
|
|
10
|
+
* Voice calls require a wallet-owned BlockRun phone number as caller ID —
|
|
11
|
+
* use BuyPhoneNumber (or ListPhoneNumbers if one already exists) before
|
|
12
|
+
* calling VoiceCall, otherwise the gateway returns 400 with the buy
|
|
13
|
+
* instructions inline.
|
|
14
|
+
*
|
|
15
|
+
* x402 payment flow mirrors src/tools/exa.ts.
|
|
16
|
+
*/
|
|
17
|
+
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
18
|
+
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
19
|
+
import { logger } from '../logger.js';
|
|
20
|
+
const VOICE_TIMEOUT_MS = 30_000;
|
|
21
|
+
// ─── Shared x402 helpers (paid POST + free GET) ───────────────────────────
|
|
22
|
+
async function postWithPayment(path, body, ctx) {
|
|
23
|
+
const chain = loadChain();
|
|
24
|
+
const apiUrl = API_URLS[chain];
|
|
25
|
+
const endpoint = `${apiUrl}${path}`;
|
|
26
|
+
const bodyStr = JSON.stringify(body);
|
|
27
|
+
const headers = {
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
'User-Agent': `franklin/${VERSION}`,
|
|
30
|
+
};
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const timeout = setTimeout(() => controller.abort(), VOICE_TIMEOUT_MS);
|
|
33
|
+
const onAbort = () => controller.abort();
|
|
34
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
35
|
+
try {
|
|
36
|
+
let response = await fetch(endpoint, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
headers,
|
|
40
|
+
body: bodyStr,
|
|
41
|
+
});
|
|
42
|
+
if (response.status === 402) {
|
|
43
|
+
const paymentHeaders = await signPayment(response, chain, endpoint);
|
|
44
|
+
if (!paymentHeaders)
|
|
45
|
+
throw new Error('Payment signing failed — check wallet balance');
|
|
46
|
+
response = await fetch(endpoint, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
headers: { ...headers, ...paymentHeaders },
|
|
50
|
+
body: bodyStr,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const errText = await response.text().catch(() => '');
|
|
55
|
+
throw new Error(`Voice ${path} failed (${response.status}): ${errText.slice(0, 400)}`);
|
|
56
|
+
}
|
|
57
|
+
return (await response.json());
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function getNoPayment(path, ctx) {
|
|
65
|
+
const chain = loadChain();
|
|
66
|
+
const apiUrl = API_URLS[chain];
|
|
67
|
+
const endpoint = `${apiUrl}${path}`;
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timeout = setTimeout(() => controller.abort(), VOICE_TIMEOUT_MS);
|
|
70
|
+
const onAbort = () => controller.abort();
|
|
71
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
72
|
+
try {
|
|
73
|
+
const resp = await fetch(endpoint, {
|
|
74
|
+
method: 'GET',
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
headers: { 'User-Agent': `franklin/${VERSION}` },
|
|
77
|
+
});
|
|
78
|
+
if (!resp.ok) {
|
|
79
|
+
const errText = await resp.text().catch(() => '');
|
|
80
|
+
throw new Error(`Voice ${path} failed (${resp.status}): ${errText.slice(0, 300)}`);
|
|
81
|
+
}
|
|
82
|
+
return (await resp.json());
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(timeout);
|
|
86
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function signPayment(response, chain, endpoint) {
|
|
90
|
+
try {
|
|
91
|
+
const paymentHeader = await extractPaymentReq(response);
|
|
92
|
+
if (!paymentHeader)
|
|
93
|
+
return null;
|
|
94
|
+
if (chain === 'solana') {
|
|
95
|
+
const wallet = await getOrCreateSolanaWallet();
|
|
96
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
97
|
+
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
|
|
98
|
+
const secretBytes = await solanaKeyToBytes(wallet.privateKey);
|
|
99
|
+
const feePayer = details.extra?.feePayer || details.recipient;
|
|
100
|
+
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
101
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
102
|
+
resourceDescription: details.resource?.description || 'Franklin voice call',
|
|
103
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
|
|
104
|
+
extra: details.extra,
|
|
105
|
+
});
|
|
106
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
107
|
+
}
|
|
108
|
+
const wallet = getOrCreateWallet();
|
|
109
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
110
|
+
const details = extractPaymentDetails(paymentRequired);
|
|
111
|
+
const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
|
|
112
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
113
|
+
resourceDescription: details.resource?.description || 'Franklin voice call',
|
|
114
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
|
|
115
|
+
extra: details.extra,
|
|
116
|
+
});
|
|
117
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
logger.warn(`[franklin] Voice payment error: ${err.message}`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function extractPaymentReq(response) {
|
|
125
|
+
let header = response.headers.get('payment-required');
|
|
126
|
+
if (!header) {
|
|
127
|
+
try {
|
|
128
|
+
const body = (await response.json());
|
|
129
|
+
if (body.x402 || body.accepts)
|
|
130
|
+
header = btoa(JSON.stringify(body));
|
|
131
|
+
}
|
|
132
|
+
catch { /* not JSON */ }
|
|
133
|
+
}
|
|
134
|
+
return header;
|
|
135
|
+
}
|
|
136
|
+
// ─── Tools ─────────────────────────────────────────────────────────────────
|
|
137
|
+
export const voiceCallCapability = {
|
|
138
|
+
spec: {
|
|
139
|
+
name: 'VoiceCall',
|
|
140
|
+
description: 'Make an outbound AI-powered phone call via Bland.ai. The AI agent on the other end ' +
|
|
141
|
+
'follows the `task` description in natural language. Cost: $0.54 flat per call (up to 5 min ' +
|
|
142
|
+
'default, 30 min max). Returns a call_id immediately; the call runs asynchronously. Use ' +
|
|
143
|
+
'VoiceStatus with the same call_id to poll transcript / recording / disposition.\n\n' +
|
|
144
|
+
'Common use cases: appointment reminders, verification callbacks, voice surveys, customer ' +
|
|
145
|
+
'outreach, OTP retrieval, two-party verification calls.\n\n' +
|
|
146
|
+
'Requirements:\n' +
|
|
147
|
+
' - `from` MUST be a wallet-owned BlockRun phone number — use ListPhoneNumbers to find ' +
|
|
148
|
+
'one or BuyPhoneNumber to provision one ($5, 30-day lease).\n' +
|
|
149
|
+
' - `to` and `from` must be E.164 format (+ country code prefix, e.g. +14155552671).\n' +
|
|
150
|
+
' - `task` must be ≥10 chars, ≤4000 chars.\n' +
|
|
151
|
+
' - US/CA destinations only.',
|
|
152
|
+
input_schema: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
to: {
|
|
156
|
+
type: 'string',
|
|
157
|
+
description: 'Recipient phone number in E.164 format, e.g. +14155552671.',
|
|
158
|
+
},
|
|
159
|
+
from: {
|
|
160
|
+
type: 'string',
|
|
161
|
+
description: 'Caller ID — must be a phone number your wallet owns via BlockRun (provision with ' +
|
|
162
|
+
'BuyPhoneNumber). E.164 format.',
|
|
163
|
+
},
|
|
164
|
+
task: {
|
|
165
|
+
type: 'string',
|
|
166
|
+
description: 'Natural-language description of what the AI should do on the call. Min 10 chars, ' +
|
|
167
|
+
'max 4000. Example: "Greet the person, confirm their 3 pm appointment for Thursday, ' +
|
|
168
|
+
'and ask if they need to reschedule. Speak warmly and end the call after confirmation."',
|
|
169
|
+
},
|
|
170
|
+
voice: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
enum: ['nat', 'josh', 'maya', 'june', 'paige', 'derek', 'florian'],
|
|
173
|
+
description: 'Voice preset (default: maya). Try josh/derek for male voices, maya/june/paige for ' +
|
|
174
|
+
'female, nat for neutral.',
|
|
175
|
+
},
|
|
176
|
+
max_duration: {
|
|
177
|
+
type: 'integer',
|
|
178
|
+
minimum: 1,
|
|
179
|
+
maximum: 30,
|
|
180
|
+
description: 'Maximum call length in minutes (1–30, default: 5).',
|
|
181
|
+
},
|
|
182
|
+
language: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description: 'Language code for STT/TTS (default: en-US). Bland supports zh-CN, es-ES, etc.',
|
|
185
|
+
},
|
|
186
|
+
first_sentence: {
|
|
187
|
+
type: 'string',
|
|
188
|
+
description: 'Optional fixed opening line spoken before the AI takes over (≤500 chars).',
|
|
189
|
+
},
|
|
190
|
+
wait_for_greeting: {
|
|
191
|
+
type: 'boolean',
|
|
192
|
+
description: 'If true, AI waits for the recipient to speak first before talking.',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
required: ['to', 'from', 'task'],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
execute: async (input, ctx) => {
|
|
199
|
+
if (typeof input.to !== 'string')
|
|
200
|
+
return { output: 'to (E.164) required', isError: true };
|
|
201
|
+
if (typeof input.from !== 'string')
|
|
202
|
+
return { output: 'from (wallet-owned E.164) required — use ListPhoneNumbers / BuyPhoneNumber', isError: true };
|
|
203
|
+
if (typeof input.task !== 'string' || input.task.length < 10) {
|
|
204
|
+
return { output: 'task required (10–4000 chars natural-language description)', isError: true };
|
|
205
|
+
}
|
|
206
|
+
const body = {
|
|
207
|
+
to: input.to,
|
|
208
|
+
from: input.from,
|
|
209
|
+
task: input.task,
|
|
210
|
+
};
|
|
211
|
+
// The gateway validates additionalProperties: false — only forward known
|
|
212
|
+
// optional fields, don't echo back whatever the caller passed.
|
|
213
|
+
if (typeof input.voice === 'string')
|
|
214
|
+
body.voice = input.voice;
|
|
215
|
+
if (typeof input.max_duration === 'number')
|
|
216
|
+
body.max_duration = input.max_duration;
|
|
217
|
+
if (typeof input.language === 'string')
|
|
218
|
+
body.language = input.language;
|
|
219
|
+
if (typeof input.first_sentence === 'string')
|
|
220
|
+
body.first_sentence = input.first_sentence;
|
|
221
|
+
if (typeof input.wait_for_greeting === 'boolean')
|
|
222
|
+
body.wait_for_greeting = input.wait_for_greeting;
|
|
223
|
+
try {
|
|
224
|
+
const res = await postWithPayment('/v1/voice/call', body, ctx);
|
|
225
|
+
const callId = (res.call_id || res.id);
|
|
226
|
+
return {
|
|
227
|
+
output: `## Voice call initiated ($0.54 USDC charged)\n\n` +
|
|
228
|
+
(callId
|
|
229
|
+
? `**call_id:** \`${callId}\`\n\nPoll with VoiceStatus call_id="${callId}" to get the ` +
|
|
230
|
+
`transcript and disposition. The call typically completes in 1–6 minutes.\n\n`
|
|
231
|
+
: '') +
|
|
232
|
+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
return { output: `Voice call failed: ${err.message}`, isError: true };
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
export const voiceStatusCapability = {
|
|
241
|
+
spec: {
|
|
242
|
+
name: 'VoiceStatus',
|
|
243
|
+
description: 'Poll a previously-initiated voice call for its current status, transcript, recording URL, ' +
|
|
244
|
+
'and final disposition (completed / failed / no-answer / busy / voicemail). Free — no USDC ' +
|
|
245
|
+
'charged. Use the call_id returned by VoiceCall. Call this every 30–60 s until status is ' +
|
|
246
|
+
'a terminal state.',
|
|
247
|
+
input_schema: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: {
|
|
250
|
+
call_id: {
|
|
251
|
+
type: 'string',
|
|
252
|
+
description: 'The call_id returned by a prior VoiceCall.',
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
required: ['call_id'],
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
execute: async (input, ctx) => {
|
|
259
|
+
if (typeof input.call_id !== 'string') {
|
|
260
|
+
return { output: 'call_id required', isError: true };
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const res = await getNoPayment(`/v1/voice/call/${encodeURIComponent(input.call_id)}`, ctx);
|
|
264
|
+
return {
|
|
265
|
+
output: `## Voice call status\n\n` +
|
|
266
|
+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
return { output: `VoiceStatus failed: ${err.message}`, isError: true };
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
};
|
package/package.json
CHANGED