Skip to content

Commit 465b249

Browse files
feat(tools): typed Phone + Voice tools — fixes agent discovery for phone/voice (#58)
Phone and voice were previously only reachable via the generic `blockrun` primitive — the agent had to know to POST /v1/phone/numbers/list or /v1/voice/call by string and hand-craft the body shape. In practice this meant models either (1) refused outright because no tool's name pattern- matched "make a phone call", or (2) tried to discover the schema by GETing /.well-known/x402 — which currently omits phone/voice — and concluded the endpoints don't exist. This adds 8 typed tools wrapping the same endpoints, named to match intent: ListPhoneNumbers — $0.001 POST /v1/phone/numbers/list BuyPhoneNumber — $5 POST /v1/phone/numbers/buy RenewPhoneNumber — $5 POST /v1/phone/numbers/renew ReleasePhoneNumber — free POST /v1/phone/numbers/release PhoneLookup — $0.01 POST /v1/phone/lookup PhoneFraudCheck — $0.05 POST /v1/phone/lookup/fraud VoiceCall — $0.54 POST /v1/voice/call (Bland.ai) VoiceStatus — free GET /v1/voice/call/{id} Each spec.description spells out the use case, cost, and required fields explicitly (incl. that VoiceCall needs a wallet-owned `from` number from BuyPhoneNumber first). This matches the existing ImageGen/VideoGen/ ExaSearch/SearchX pattern — agents grep tool names and select on the first hit instead of opening discovery manifests. x402 payment flow mirrors src/tools/exa.ts (Base + Solana). VoiceStatus is the only GET, and it's free — no payment signing path. Repro for the discovery failure mode this fixes: 1. User: "list my BlockRun phone numbers" 2. Opus 4.7 with old tool list: calls blockrun → real number returned 3. User: "now call +1..." 4. Opus loses tool_result to compaction, distrusts memory, GETs /.well-known/x402, doesn't see phone, retracts the earlier answer. With this PR Opus picks VoiceCall by name in step 3, never opens the discovery manifest, and the conversation flows naturally. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3ac41e4 commit 465b249

3 files changed

Lines changed: 687 additions & 1 deletion

File tree

src/tools/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ import {
4343
import { predictionMarketCapability } from './prediction.js';
4444
import { modalCapabilities } from './modal.js';
4545
import { blockrunCapability } from './blockrun.js';
46+
import {
47+
listPhoneNumbersCapability,
48+
buyPhoneNumberCapability,
49+
renewPhoneNumberCapability,
50+
releasePhoneNumberCapability,
51+
phoneLookupCapability,
52+
phoneFraudCheckCapability,
53+
} from './phone.js';
54+
import { voiceCallCapability, voiceStatusCapability } from './voice.js';
4655
import { createTradingCapabilities } from './trading-execute.js';
4756
import { Portfolio } from '../trading/portfolio.js';
4857
import { RiskEngine } from '../trading/risk.js';
@@ -186,7 +195,19 @@ export const allCapabilities: CapabilityHandler[] = [
186195
defiLlamaYieldsCapability,
187196
defiLlamaPriceCapability,
188197
predictionMarketCapability, // Polymarket / Kalshi / matching / smart money via Predexon
189-
blockrunCapability, // Generic x402-paid gateway primitive — Surf, Phone, future partners (see /surf-* skills)
198+
blockrunCapability, // Generic x402-paid gateway primitive — Surf, future partners (see /surf-* skills)
199+
// Phone & Voice — typed surface so the agent pattern-matches on the user
200+
// intent ("buy a number", "make a call") without needing to consult the
201+
// BlockRun primitive or the .well-known/x402 manifest. All wrap the same
202+
// /v1/phone/* and /v1/voice/* endpoints under the hood.
203+
listPhoneNumbersCapability, // ListPhoneNumbers — $0.001
204+
buyPhoneNumberCapability, // BuyPhoneNumber — $5 / 30 days
205+
renewPhoneNumberCapability, // RenewPhoneNumber — $5 / 30 days
206+
releasePhoneNumberCapability, // ReleasePhoneNumber — free
207+
phoneLookupCapability, // PhoneLookup — $0.01
208+
phoneFraudCheckCapability, // PhoneFraudCheck — $0.05
209+
voiceCallCapability, // VoiceCall — $0.54 / call (Bland.ai)
210+
voiceStatusCapability, // VoiceStatus — free (poll)
190211
// Modal GPU sandbox tools — registered but hidden by default (not in
191212
// CORE_TOOL_NAMES). Agent must `ActivateTool({names:["ModalCreate",...]})`
192213
// before they appear in its tool inventory. High-cost ($0.40/H100 create)

src/tools/phone.ts

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
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+
15+
import {
16+
getOrCreateWallet,
17+
getOrCreateSolanaWallet,
18+
createPaymentPayload,
19+
createSolanaPaymentPayload,
20+
parsePaymentRequired,
21+
extractPaymentDetails,
22+
solanaKeyToBytes,
23+
SOLANA_NETWORK,
24+
} from '@blockrun/llm';
25+
import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js';
26+
import { loadChain, API_URLS, VERSION } from '../config.js';
27+
import { logger } from '../logger.js';
28+
29+
const PHONE_TIMEOUT_MS = 30_000;
30+
31+
// ─── Shared payment flow (POST) ───────────────────────────────────────────
32+
33+
async function postWithPayment<T>(
34+
path: string,
35+
body: unknown,
36+
ctx: ExecutionScope,
37+
): Promise<T> {
38+
const chain = loadChain();
39+
const apiUrl = API_URLS[chain];
40+
const endpoint = `${apiUrl}${path}`;
41+
const bodyStr = JSON.stringify(body);
42+
const headers: Record<string, string> = {
43+
'Content-Type': 'application/json',
44+
'User-Agent': `franklin/${VERSION}`,
45+
};
46+
47+
const controller = new AbortController();
48+
const timeout = setTimeout(() => controller.abort(), PHONE_TIMEOUT_MS);
49+
const onAbort = () => controller.abort();
50+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
51+
52+
try {
53+
let response = await fetch(endpoint, {
54+
method: 'POST',
55+
signal: controller.signal,
56+
headers,
57+
body: bodyStr,
58+
});
59+
60+
if (response.status === 402) {
61+
const paymentHeaders = await signPayment(response, chain, endpoint, 'Franklin phone');
62+
if (!paymentHeaders) throw new Error('Payment signing failed — check wallet balance');
63+
response = await fetch(endpoint, {
64+
method: 'POST',
65+
signal: controller.signal,
66+
headers: { ...headers, ...paymentHeaders },
67+
body: bodyStr,
68+
});
69+
}
70+
71+
if (!response.ok) {
72+
const errText = await response.text().catch(() => '');
73+
throw new Error(`Phone ${path} failed (${response.status}): ${errText.slice(0, 300)}`);
74+
}
75+
return (await response.json()) as T;
76+
} finally {
77+
clearTimeout(timeout);
78+
ctx.abortSignal.removeEventListener('abort', onAbort);
79+
}
80+
}
81+
82+
async function signPayment(
83+
response: Response,
84+
chain: 'base' | 'solana',
85+
endpoint: string,
86+
description: string,
87+
): Promise<Record<string, string> | null> {
88+
try {
89+
const paymentHeader = await extractPaymentReq(response);
90+
if (!paymentHeader) return null;
91+
92+
if (chain === 'solana') {
93+
const wallet = await getOrCreateSolanaWallet();
94+
const paymentRequired = parsePaymentRequired(paymentHeader);
95+
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
96+
const secretBytes = await solanaKeyToBytes(wallet.privateKey);
97+
const feePayer = details.extra?.feePayer || details.recipient;
98+
const payload = await createSolanaPaymentPayload(
99+
secretBytes,
100+
wallet.address,
101+
details.recipient,
102+
details.amount,
103+
feePayer as string,
104+
{
105+
resourceUrl: details.resource?.url || endpoint,
106+
resourceDescription: details.resource?.description || description,
107+
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
108+
extra: details.extra as Record<string, unknown> | undefined,
109+
},
110+
);
111+
return { 'PAYMENT-SIGNATURE': payload };
112+
}
113+
const wallet = getOrCreateWallet();
114+
const paymentRequired = parsePaymentRequired(paymentHeader);
115+
const details = extractPaymentDetails(paymentRequired);
116+
const payload = await createPaymentPayload(
117+
wallet.privateKey as `0x${string}`,
118+
wallet.address,
119+
details.recipient,
120+
details.amount,
121+
details.network || 'eip155:8453',
122+
{
123+
resourceUrl: details.resource?.url || endpoint,
124+
resourceDescription: details.resource?.description || description,
125+
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
126+
extra: details.extra as Record<string, unknown> | undefined,
127+
},
128+
);
129+
return { 'PAYMENT-SIGNATURE': payload };
130+
} catch (err) {
131+
logger.warn(`[franklin] Phone payment error: ${(err as Error).message}`);
132+
return null;
133+
}
134+
}
135+
136+
async function extractPaymentReq(response: Response): Promise<string | null> {
137+
let header = response.headers.get('payment-required');
138+
if (!header) {
139+
try {
140+
const body = (await response.json()) as Record<string, unknown>;
141+
if (body.x402 || body.accepts) header = btoa(JSON.stringify(body));
142+
} catch { /* not JSON */ }
143+
}
144+
return header;
145+
}
146+
147+
// ─── Tools ─────────────────────────────────────────────────────────────────
148+
149+
export const listPhoneNumbersCapability: CapabilityHandler = {
150+
spec: {
151+
name: 'ListPhoneNumbers',
152+
description:
153+
'List the phone numbers your wallet currently owns (US/CA, leased 30 days at a time). ' +
154+
'Use this before any phone-related action to remind the agent what numbers are available. ' +
155+
'Costs $0.001 USDC. Returns each number with country, area code, expiration timestamp, ' +
156+
'and current status (active/expiring/expired).',
157+
input_schema: { type: 'object', properties: {} },
158+
},
159+
execute: async (_input, ctx): Promise<CapabilityResult> => {
160+
try {
161+
const res = await postWithPayment<Record<string, unknown>>('/v1/phone/numbers/list', {}, ctx);
162+
return {
163+
output:
164+
`## Phone numbers (wallet-owned)\n\n` +
165+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
166+
};
167+
} catch (err) {
168+
return { output: `Phone list failed: ${(err as Error).message}`, isError: true };
169+
}
170+
},
171+
};
172+
173+
export const buyPhoneNumberCapability: CapabilityHandler = {
174+
spec: {
175+
name: 'BuyPhoneNumber',
176+
description:
177+
'Provision a new US or CA phone number for the wallet for 30 days. Costs $5 USDC. ' +
178+
'Optionally pin a 3-digit area code (best effort). The provisioned number is auto-registered ' +
179+
'as a valid caller ID for outbound VoiceCall. A wallet can hold multiple numbers; this adds ' +
180+
'one, never replaces. To pick the country: country="US" (default) or country="CA".',
181+
input_schema: {
182+
type: 'object',
183+
properties: {
184+
country: { type: 'string', enum: ['US', 'CA'], description: 'Country code (default: US)' },
185+
area_code: { type: 'string', description: 'Preferred 3-digit area code (best effort)' },
186+
},
187+
},
188+
},
189+
execute: async (input, ctx): Promise<CapabilityResult> => {
190+
const body: Record<string, string> = {};
191+
if (typeof input.country === 'string') body.country = input.country;
192+
if (typeof input.area_code === 'string') body.areaCode = input.area_code;
193+
try {
194+
const res = await postWithPayment<Record<string, unknown>>('/v1/phone/numbers/buy', body, ctx);
195+
return {
196+
output:
197+
`## Number provisioned ($5 USDC charged)\n\n` +
198+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
199+
};
200+
} catch (err) {
201+
return { output: `Buy failed: ${(err as Error).message}`, isError: true };
202+
}
203+
},
204+
};
205+
206+
export const renewPhoneNumberCapability: CapabilityHandler = {
207+
spec: {
208+
name: 'RenewPhoneNumber',
209+
description:
210+
'Extend the 30-day lease on a wallet-owned phone number. Costs $5 USDC. Use ListPhoneNumbers ' +
211+
'first to confirm the number is yours. Released or expired numbers cannot be renewed — buy a ' +
212+
'new one with BuyPhoneNumber instead.',
213+
input_schema: {
214+
type: 'object',
215+
properties: {
216+
phone_number: { type: 'string', description: 'E.164 format, e.g. +14155552671' },
217+
},
218+
required: ['phone_number'],
219+
},
220+
},
221+
execute: async (input, ctx): Promise<CapabilityResult> => {
222+
if (typeof input.phone_number !== 'string') {
223+
return { output: 'phone_number (E.164) required', isError: true };
224+
}
225+
try {
226+
const res = await postWithPayment<Record<string, unknown>>(
227+
'/v1/phone/numbers/renew',
228+
{ phoneNumber: input.phone_number },
229+
ctx,
230+
);
231+
return {
232+
output:
233+
`## Lease renewed (+30 days, $5 USDC charged)\n\n` +
234+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
235+
};
236+
} catch (err) {
237+
return { output: `Renew failed: ${(err as Error).message}`, isError: true };
238+
}
239+
},
240+
};
241+
242+
export const releasePhoneNumberCapability: CapabilityHandler = {
243+
spec: {
244+
name: 'ReleasePhoneNumber',
245+
description:
246+
'Release a wallet-owned phone number back to the BlockRun pool before its lease expires. ' +
247+
'Free. The number is gone after this — it may be picked up by another wallet. Use when you ' +
248+
"no longer need a test number and want it out of your ListPhoneNumbers result.",
249+
input_schema: {
250+
type: 'object',
251+
properties: {
252+
phone_number: { type: 'string', description: 'E.164 format, e.g. +14155552671' },
253+
},
254+
required: ['phone_number'],
255+
},
256+
},
257+
execute: async (input, ctx): Promise<CapabilityResult> => {
258+
if (typeof input.phone_number !== 'string') {
259+
return { output: 'phone_number (E.164) required', isError: true };
260+
}
261+
try {
262+
const res = await postWithPayment<Record<string, unknown>>(
263+
'/v1/phone/numbers/release',
264+
{ phoneNumber: input.phone_number },
265+
ctx,
266+
);
267+
return {
268+
output:
269+
`## Number released (free)\n\n` +
270+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
271+
};
272+
} catch (err) {
273+
return { output: `Release failed: ${(err as Error).message}`, isError: true };
274+
}
275+
},
276+
};
277+
278+
export const phoneLookupCapability: CapabilityHandler = {
279+
spec: {
280+
name: 'PhoneLookup',
281+
description:
282+
'Look up carrier and line type information for ANY phone number (does not need to be ' +
283+
'wallet-owned). Returns carrier name, line type (mobile/landline/voip), country, and ' +
284+
'portability info. Costs $0.01 USDC. Use to validate a number before texting/calling or ' +
285+
'to figure out whether a contact number is a real mobile.',
286+
input_schema: {
287+
type: 'object',
288+
properties: {
289+
phone_number: { type: 'string', description: 'E.164 format, e.g. +14155552671' },
290+
},
291+
required: ['phone_number'],
292+
},
293+
},
294+
execute: async (input, ctx): Promise<CapabilityResult> => {
295+
if (typeof input.phone_number !== 'string') {
296+
return { output: 'phone_number (E.164) required', isError: true };
297+
}
298+
try {
299+
const res = await postWithPayment<Record<string, unknown>>(
300+
'/v1/phone/lookup',
301+
{ phoneNumber: input.phone_number },
302+
ctx,
303+
);
304+
return {
305+
output:
306+
`## Phone lookup ($0.01 USDC charged)\n\n` +
307+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
308+
};
309+
} catch (err) {
310+
return { output: `Lookup failed: ${(err as Error).message}`, isError: true };
311+
}
312+
},
313+
};
314+
315+
export const phoneFraudCheckCapability: CapabilityHandler = {
316+
spec: {
317+
name: 'PhoneFraudCheck',
318+
description:
319+
'Run a fraud / risk assessment on a phone number — checks SIM swap signals, call forwarding ' +
320+
'status, and known-spam reputation. Returns a risk score and signal breakdown. Costs $0.05 ' +
321+
'USDC. Use before sending OTPs or trusting a phone for account recovery.',
322+
input_schema: {
323+
type: 'object',
324+
properties: {
325+
phone_number: { type: 'string', description: 'E.164 format, e.g. +14155552671' },
326+
},
327+
required: ['phone_number'],
328+
},
329+
},
330+
execute: async (input, ctx): Promise<CapabilityResult> => {
331+
if (typeof input.phone_number !== 'string') {
332+
return { output: 'phone_number (E.164) required', isError: true };
333+
}
334+
try {
335+
const res = await postWithPayment<Record<string, unknown>>(
336+
'/v1/phone/lookup/fraud',
337+
{ phoneNumber: input.phone_number },
338+
ctx,
339+
);
340+
return {
341+
output:
342+
`## Fraud check ($0.05 USDC charged)\n\n` +
343+
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
344+
};
345+
} catch (err) {
346+
return { output: `Fraud check failed: ${(err as Error).message}`, isError: true };
347+
}
348+
},
349+
};

0 commit comments

Comments
 (0)