@blockrun/franklin 3.20.1 → 3.21.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.
@@ -296,6 +296,14 @@ On-chain affiliate (20 bps in sell-token, force-set server-side) flows to BlockR
296
296
  - \`/v1/modal/{...path}\` — Modal GPU sandbox passthrough (create/exec/etc.).
297
297
  - \`/v1/pm/{...path}\` — prediction-market data passthrough.
298
298
 
299
+ **Phone & Voice (typed tools — prefer these over raw primitive calls)**
300
+ - \`ListPhoneNumbers\` (\$0.001) / \`BuyPhoneNumber\` (\$5, 30-day lease) / \`RenewPhoneNumber\` (\$5) / \`ReleasePhoneNumber\` (free) — lifecycle of wallet-owned BlockRun numbers.
301
+ - \`PhoneLookup\` (\$0.01) / \`PhoneFraudCheck\` (\$0.05) — carrier + risk lookup.
302
+ - \`VoiceCall\` (\$0.54, POST /v1/voice/call) — place an outbound AI-driven call. Async — returns \`call_id\` immediately.
303
+ - \`VoiceStatus\` (free, GET /v1/voice/call/{id}) — poll a previously-initiated call for status / transcript / recording / disposition.
304
+ - For end-to-end voice workflows including auto-poll, confirmation gates, and compliance reminders, prefer the bundled **\`/phone-call\`** skill — it walks through intent capture, caller-ID selection, task scripting, confirmation, and the polling loop. Calls auto-journal to ~/.blockrun/calls.jsonl (visible in the panel "Calls" tab).
305
+ - US/CA destinations only. Marketing/sales calls require prior express consent (TCPA).
306
+
299
307
  **Surf — crypto data + chat (x402-paid)** via the generic \`BlockRun\` capability. ~55 curated endpoints. Tier-1 $0.001, Tier-2 $0.005, Tier-3 / chat $0.02.
300
308
  - \`/v1/surf/exchange/*\` — CEX trading pairs, prices, perps, depth, klines, funding history, long/short ratio.
301
309
  - \`/v1/surf/market/*\` — token rankings, fear/greed, futures, ETF flows, options skew, liquidations, on-chain indicators (NUPL/SOPR/MVRV), price indicators (RSI/MACD/BBANDS).
@@ -307,7 +315,7 @@ On-chain affiliate (20 bps in sell-token, force-set server-side) flows to BlockR
307
315
  - \`/v1/surf/fund/{detail,portfolio,ranking}\` — VC fund profiles, portfolios, ranking.
308
316
  - \`/v1/surf/project/{detail,defi/metrics,defi/ranking}\` — project profiles + DeFi protocol metrics.
309
317
 
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\` (surf-1.5) is temporarily disabled in v3.20.1 the BlockRun gateway's upstream path returns 404 from Surf; will be re-enabled in a follow-up release once the gateway side is fixed.
318
+ 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
319
 
312
320
  **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
321
 
@@ -517,6 +517,10 @@ a:hover { text-decoration:underline; }
517
517
  Phone
518
518
  <span class="nav-badge" id="phone-nav-badge" style="display:none"></span>
519
519
  </button>
520
+ <button class="nav-item" data-tab="calls">
521
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10v4"/><path d="M7 8v8"/><path d="M11 5v14"/><path d="M15 8v8"/><path d="M19 10v4"/></svg>
522
+ Calls
523
+ </button>
520
524
  <button class="nav-item" data-tab="sessions">
521
525
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
522
526
  Sessions
@@ -779,6 +783,27 @@ a:hover { text-decoration:underline; }
779
783
  </div>
780
784
  </div>
781
785
 
786
+ <!-- Calls -->
787
+ <div class="tab" id="tab-calls">
788
+ <div class="content-header">
789
+ <h2>Calls</h2>
790
+ <p>Recent outbound voice calls fired through VoiceCall. Reads from ~/.blockrun/calls.jsonl &mdash; the journal Franklin keeps locally as it polls call status.</p>
791
+ </div>
792
+
793
+ <div class="card">
794
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">
795
+ <h3 style="margin:0">Recent calls</h3>
796
+ <div style="display:flex;gap:8px;align-items:center">
797
+ <span class="phone-status" id="calls-list-status"></span>
798
+ <button class="btn btn-ghost" id="calls-refresh-btn" title="Reload from journal">Refresh</button>
799
+ </div>
800
+ </div>
801
+ <div id="calls-list" style="margin-top:12px">
802
+ <div class="phone-empty">Loading&hellip;</div>
803
+ </div>
804
+ </div>
805
+ </div>
806
+
782
807
  <!-- Learnings -->
783
808
  <div class="tab" id="tab-learnings">
784
809
  <div class="content-header">
@@ -1947,6 +1972,96 @@ document.getElementById('phone-buy-btn')?.addEventListener('click', buyPhoneNumb
1947
1972
  // clicks into the Phone tab. Cached read — no network cost.
1948
1973
  loadPhone({});
1949
1974
 
1975
+ // ─── Calls tab ──────────────────────────────────────────────────────────
1976
+ // Read-only view of ~/.blockrun/calls.jsonl. VoiceCall and VoiceStatus tools
1977
+ // write to that journal; this tab just reads.
1978
+
1979
+ function formatCallStatus(status) {
1980
+ const s = String(status || '').toLowerCase();
1981
+ if (s === 'completed') return { label: 'completed', cls: 'green' };
1982
+ if (s === 'queued' || s === 'in_progress' || s === 'in-progress') return { label: s.replace('_',' '), cls: 'amber' };
1983
+ return { label: s || 'unknown', cls: 'red' };
1984
+ }
1985
+
1986
+ function formatDuration(sec) {
1987
+ if (!sec || typeof sec !== 'number') return '—';
1988
+ const m = Math.floor(sec / 60);
1989
+ const s = sec % 60;
1990
+ return m > 0 ? m + 'm ' + s + 's' : s + 's';
1991
+ }
1992
+
1993
+ function renderCallsList(calls) {
1994
+ const list = document.getElementById('calls-list');
1995
+ if (!list) return;
1996
+ if (!calls || calls.length === 0) {
1997
+ list.innerHTML = '<div class="phone-empty">' +
1998
+ '<strong>No calls yet</strong>' +
1999
+ 'Outbound voice calls fired via the <code>VoiceCall</code> tool or the <code>/phone-call</code> skill ' +
2000
+ 'will appear here. Each call costs $0.54 and requires a wallet-owned BlockRun phone number as caller ID.' +
2001
+ '</div>';
2002
+ return;
2003
+ }
2004
+ const html = calls.map(c => {
2005
+ const st = formatCallStatus(c.status);
2006
+ const human = formatPhoneNumber(c.to);
2007
+ const fromHuman = formatPhoneNumber(c.from);
2008
+ const when = new Date(c.timestamp).toLocaleString();
2009
+ const cost = c.paid_usd ? '$' + c.paid_usd.toFixed(2) : '—';
2010
+ const safeTask = (c.task || '').replace(/[<>&]/g, ch => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[ch]));
2011
+ const transcriptHtml = c.transcript
2012
+ ? '<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--text-dim);font-size:12px">Transcript</summary><pre style="white-space:pre-wrap;background:oklch(0 0 0 / 25%);padding:10px;border-radius:8px;font-size:12px;margin-top:6px;max-height:400px;overflow:auto">' +
2013
+ c.transcript.replace(/[<>&]/g, ch => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[ch])) + '</pre></details>'
2014
+ : '';
2015
+ const recordingHtml = c.recording_url
2016
+ ? '<a href="' + c.recording_url + '" target="_blank" rel="noopener" style="font-size:11px;color:var(--brand);text-decoration:none;margin-left:8px">▶ recording</a>'
2017
+ : '';
2018
+ return ''
2019
+ + '<div class="phone-row" style="grid-template-columns:auto 1fr auto;align-items:start">'
2020
+ + ' <div class="phone-icon-bubble">'
2021
+ + ' <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
2022
+ + ' <path d="M3 10v4"/><path d="M7 8v8"/><path d="M11 5v14"/><path d="M15 8v8"/><path d="M19 10v4"/>'
2023
+ + ' </svg>'
2024
+ + ' </div>'
2025
+ + ' <div class="phone-main">'
2026
+ + ' <div class="phone-num">' + human + ' <span style="font-size:11px;color:var(--text-dim);font-weight:400">from ' + fromHuman + '</span></div>'
2027
+ + ' <div class="phone-meta">'
2028
+ + ' <span class="chip ' + st.cls + '">' + st.label + '</span>'
2029
+ + ' <span class="chip">' + formatDuration(c.duration_sec) + '</span>'
2030
+ + ' <span class="chip">' + cost + '</span>'
2031
+ + ' <span style="font-size:11px;color:var(--text-dim)">' + when + '</span>'
2032
+ + recordingHtml
2033
+ + ' </div>'
2034
+ + ' <div style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.5">' + (safeTask.slice(0, 200) + (safeTask.length > 200 ? '…' : '')) + '</div>'
2035
+ + transcriptHtml
2036
+ + ' </div>'
2037
+ + '</div>';
2038
+ }).join('');
2039
+ list.innerHTML = html;
2040
+ }
2041
+
2042
+ async function loadCalls() {
2043
+ const statusEl = document.getElementById('calls-list-status');
2044
+ if (statusEl) { statusEl.textContent = 'Loading…'; statusEl.className = 'phone-status'; }
2045
+ try {
2046
+ const r = await fetch('/api/calls?limit=50');
2047
+ const data = await r.json();
2048
+ if (!r.ok) {
2049
+ if (statusEl) { statusEl.textContent = data.error || 'Failed to load'; statusEl.className = 'phone-status err'; }
2050
+ return;
2051
+ }
2052
+ renderCallsList(data.calls || []);
2053
+ if (statusEl) {
2054
+ statusEl.className = 'phone-status';
2055
+ statusEl.textContent = data.count + ' call' + (data.count === 1 ? '' : 's');
2056
+ }
2057
+ } catch (err) {
2058
+ if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
2059
+ }
2060
+ }
2061
+
2062
+ document.querySelector('[data-tab="calls"]')?.addEventListener('click', loadCalls);
2063
+ document.getElementById('calls-refresh-btn')?.addEventListener('click', loadCalls);
2064
+
1950
2065
  loadOverview();
1951
2066
  loadSessions();
1952
2067
  loadMarkets();
@@ -575,6 +575,54 @@ export function createPanelServer(port) {
575
575
  }
576
576
  return;
577
577
  }
578
+ // ─── Calls (voice call journal) ─────────────────────────────────────
579
+ // Read-only views of ~/.blockrun/calls.jsonl. VoiceCall and VoiceStatus
580
+ // tools write to that journal; this endpoint just summarizes it for the
581
+ // panel "Calls" tab. No x402, no wallet-mutating action — same loopback
582
+ // posture as the rest of the panel anyway since the journal can contain
583
+ // call transcripts and recipient numbers.
584
+ if (p === '/api/calls' && (!req.method || req.method === 'GET')) {
585
+ if (!isLocalPanelRequest(req)) {
586
+ json(res, { error: 'forbidden', calls: [] }, 403);
587
+ return;
588
+ }
589
+ try {
590
+ const { CallLog } = await import('../phone/call-log.js');
591
+ const log = new CallLog();
592
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 200);
593
+ const calls = log.summary(limit);
594
+ json(res, { calls, count: calls.length });
595
+ }
596
+ catch (err) {
597
+ json(res, { error: err.message, calls: [] }, 500);
598
+ }
599
+ return;
600
+ }
601
+ if (p.startsWith('/api/calls/') && (!req.method || req.method === 'GET')) {
602
+ if (!isLocalPanelRequest(req)) {
603
+ json(res, { error: 'forbidden' }, 403);
604
+ return;
605
+ }
606
+ try {
607
+ const callId = decodeURIComponent(p.slice('/api/calls/'.length));
608
+ if (!callId) {
609
+ json(res, { error: 'call_id required' }, 400);
610
+ return;
611
+ }
612
+ const { CallLog } = await import('../phone/call-log.js');
613
+ const log = new CallLog();
614
+ const entry = log.byCallId(callId);
615
+ if (!entry) {
616
+ json(res, { error: 'not found', call_id: callId }, 404);
617
+ return;
618
+ }
619
+ json(res, entry);
620
+ }
621
+ catch (err) {
622
+ json(res, { error: err.message }, 500);
623
+ }
624
+ return;
625
+ }
578
626
  if (p === '/api/markets') {
579
627
  // Snapshot of every active data provider for the Markets panel:
580
628
  // pipeline wiring (which endpoint serves which asset class), live
@@ -0,0 +1,62 @@
1
+ /**
2
+ * CallLog — JSONL persistent record of every outbound voice call the agent
3
+ * initiates through VoiceCall (and updates as VoiceStatus polls for status).
4
+ *
5
+ * Why a journal: BlockRun's gateway doesn't expose a "list my calls" endpoint —
6
+ * /v1/voice/call/{id} works but you need to remember the id. Without local
7
+ * persistence, the panel can't show a "recent calls" view and cross-session
8
+ * memory ("did I already leave a voicemail at this number this week?") is
9
+ * impossible.
10
+ *
11
+ * Why append-only with multiple rows per call_id: calls are async, status
12
+ * mutates over time (queued → in_progress → completed). Append-only avoids
13
+ * the JSONL-rewrite-race that an in-place update would introduce — readers
14
+ * pick the latest row by call_id when summarizing. Same approach trade-log
15
+ * uses for fills vs corrections.
16
+ *
17
+ * Schema (additive over time; readers tolerate missing optional fields):
18
+ * timestamp: ms epoch of THIS log row (not the call start)
19
+ * call_id: Bland.ai call identifier (stable across rows)
20
+ * to / from: E.164 numbers
21
+ * task: the natural-language instructions the AI followed
22
+ * voice / max_duration_min / language: caller-side preferences (queue row only)
23
+ * status: queued | in_progress | completed | failed | cancelled |
24
+ * busy | no-answer | voicemail
25
+ * duration_sec: actual call length once known
26
+ * transcript: full conversation text once completed
27
+ * recording_url: Bland-hosted MP3/WAV link
28
+ * paid_usd: 0.54 charged on the initial POST; later rows carry 0
29
+ * tx_hash: x402 settlement hash for the initial POST
30
+ */
31
+ export type CallStatus = 'queued' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'busy' | 'no-answer' | 'voicemail';
32
+ export interface CallLogEntry {
33
+ timestamp: number;
34
+ call_id: string;
35
+ to: string;
36
+ from: string;
37
+ task: string;
38
+ voice?: string;
39
+ max_duration_min?: number;
40
+ language?: string;
41
+ status: CallStatus;
42
+ duration_sec?: number;
43
+ transcript?: string;
44
+ recording_url?: string;
45
+ paid_usd: number;
46
+ tx_hash?: string;
47
+ }
48
+ export declare function isTerminalStatus(s: unknown): s is CallStatus;
49
+ export declare function defaultCallLogPath(): string;
50
+ export declare class CallLog {
51
+ private filePath;
52
+ constructor(filePath?: string);
53
+ append(entry: CallLogEntry): void;
54
+ /** Read every entry on disk in chronological (append) order. */
55
+ all(): CallLogEntry[];
56
+ /**
57
+ * Latest row for each call_id, newest first by initial-row timestamp.
58
+ * This is the canonical "list of calls" view for the panel.
59
+ */
60
+ summary(limit?: number): CallLogEntry[];
61
+ byCallId(callId: string): CallLogEntry | null;
62
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * CallLog — JSONL persistent record of every outbound voice call the agent
3
+ * initiates through VoiceCall (and updates as VoiceStatus polls for status).
4
+ *
5
+ * Why a journal: BlockRun's gateway doesn't expose a "list my calls" endpoint —
6
+ * /v1/voice/call/{id} works but you need to remember the id. Without local
7
+ * persistence, the panel can't show a "recent calls" view and cross-session
8
+ * memory ("did I already leave a voicemail at this number this week?") is
9
+ * impossible.
10
+ *
11
+ * Why append-only with multiple rows per call_id: calls are async, status
12
+ * mutates over time (queued → in_progress → completed). Append-only avoids
13
+ * the JSONL-rewrite-race that an in-place update would introduce — readers
14
+ * pick the latest row by call_id when summarizing. Same approach trade-log
15
+ * uses for fills vs corrections.
16
+ *
17
+ * Schema (additive over time; readers tolerate missing optional fields):
18
+ * timestamp: ms epoch of THIS log row (not the call start)
19
+ * call_id: Bland.ai call identifier (stable across rows)
20
+ * to / from: E.164 numbers
21
+ * task: the natural-language instructions the AI followed
22
+ * voice / max_duration_min / language: caller-side preferences (queue row only)
23
+ * status: queued | in_progress | completed | failed | cancelled |
24
+ * busy | no-answer | voicemail
25
+ * duration_sec: actual call length once known
26
+ * transcript: full conversation text once completed
27
+ * recording_url: Bland-hosted MP3/WAV link
28
+ * paid_usd: 0.54 charged on the initial POST; later rows carry 0
29
+ * tx_hash: x402 settlement hash for the initial POST
30
+ */
31
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
32
+ import { dirname, join } from 'node:path';
33
+ import { homedir } from 'node:os';
34
+ /** Set of statuses that mean "no further polling needed". */
35
+ const TERMINAL_STATUSES = new Set([
36
+ 'completed',
37
+ 'failed',
38
+ 'cancelled',
39
+ 'busy',
40
+ 'no-answer',
41
+ 'voicemail',
42
+ ]);
43
+ export function isTerminalStatus(s) {
44
+ return typeof s === 'string' && TERMINAL_STATUSES.has(s);
45
+ }
46
+ export function defaultCallLogPath() {
47
+ return join(homedir(), '.blockrun', 'calls.jsonl');
48
+ }
49
+ export class CallLog {
50
+ filePath;
51
+ constructor(filePath = defaultCallLogPath()) {
52
+ this.filePath = filePath;
53
+ }
54
+ append(entry) {
55
+ try {
56
+ mkdirSync(dirname(this.filePath), { recursive: true });
57
+ appendFileSync(this.filePath, JSON.stringify(entry) + '\n', 'utf-8');
58
+ }
59
+ catch {
60
+ /* best-effort persistence; never block a call on disk failure */
61
+ }
62
+ }
63
+ /** Read every entry on disk in chronological (append) order. */
64
+ all() {
65
+ if (!existsSync(this.filePath))
66
+ return [];
67
+ let raw;
68
+ try {
69
+ raw = readFileSync(this.filePath, 'utf-8');
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ const out = [];
75
+ for (const line of raw.split('\n')) {
76
+ if (!line.trim())
77
+ continue;
78
+ try {
79
+ const obj = JSON.parse(line);
80
+ if (typeof obj?.timestamp === 'number' &&
81
+ typeof obj?.call_id === 'string' &&
82
+ typeof obj?.to === 'string' &&
83
+ typeof obj?.from === 'string' &&
84
+ typeof obj?.task === 'string' &&
85
+ typeof obj?.status === 'string' &&
86
+ typeof obj?.paid_usd === 'number') {
87
+ out.push(obj);
88
+ }
89
+ }
90
+ catch {
91
+ /* corrupt line — skip */
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ /**
97
+ * Latest row for each call_id, newest first by initial-row timestamp.
98
+ * This is the canonical "list of calls" view for the panel.
99
+ */
100
+ summary(limit = 50) {
101
+ const all = this.all();
102
+ const latest = new Map();
103
+ for (const e of all) {
104
+ const cur = latest.get(e.call_id);
105
+ // Keep the row with the FRESHEST timestamp per call_id (status updates).
106
+ if (!cur || e.timestamp >= cur.timestamp)
107
+ latest.set(e.call_id, e);
108
+ }
109
+ // Sort newest-first by the latest-row timestamp.
110
+ const list = Array.from(latest.values()).sort((a, b) => b.timestamp - a.timestamp);
111
+ return list.slice(0, limit);
112
+ }
113
+ byCallId(callId) {
114
+ let best = null;
115
+ for (const e of this.all()) {
116
+ if (e.call_id !== callId)
117
+ continue;
118
+ if (!best || e.timestamp >= best.timestamp)
119
+ best = e;
120
+ }
121
+ return best;
122
+ }
123
+ }
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: phone-call
3
+ description: Place an outbound AI-driven voice call (Bland.ai via BlockRun). Walks through intent capture, caller-ID selection, task scripting, and confirmation; fires VoiceCall, then auto-polls VoiceStatus until completion and surfaces the transcript. $0.54 per call, US/CA destinations only, charged from your wallet. Real-world action — use with prior consent.
4
+ triggers:
5
+ - "make a phone call"
6
+ - "call this number"
7
+ - "call them"
8
+ - "place a call"
9
+ - "outbound call"
10
+ - "phone call"
11
+ - "leave a voicemail"
12
+ argument-hint: <recipient + what to say>
13
+ cost-receipt: true
14
+ ---
15
+
16
+ You are running inside Franklin on **{{wallet_chain}}**. This skill is Franklin's real-world action surface — it picks up a real phone, charges a real $0.54 from the user's wallet, and the recipient is a real human (or their voicemail). Be deliberate.
17
+
18
+ ## Workflow
19
+
20
+ ### 1 · Capture intent
21
+
22
+ Read what the user said below under "The user said". Extract two things:
23
+
24
+ - **Recipient phone number** in E.164 (e.g. `+14155552671`). US/CA only — if the country code isn't `+1`, stop and tell the user the surface is US/CA only today.
25
+ - **Task** — what should the AI say / do on the call? Concrete enough that a stranger reading it cold could execute. 10–4000 chars.
26
+
27
+ If either is vague, ask **one** clarifying question. Do not fan out into multi-question interviews — phone calls aren't worth a 5-turn interrogation.
28
+
29
+ ### 2 · Pick the caller-ID
30
+
31
+ Call `ListPhoneNumbers` ($0.001) to see what the wallet owns.
32
+
33
+ - **0 active numbers** → tell the user they need to provision one first via `BuyPhoneNumber` ($5, 30-day lease). Stop. Do not auto-buy a number unless the user explicitly says so — $5 is a meaningful spend.
34
+ - **1 active number** → use it as `from`. Mention which number you're using.
35
+ - **>1 active number** → list them with expiry dates; ask the user which to use as caller-ID.
36
+
37
+ ### 3 · Craft the task script
38
+
39
+ Reasonable template — adapt to the user's intent:
40
+
41
+ ```
42
+ Greet the recipient briefly. Identify yourself as <"calling on behalf of <user>" or
43
+ similar>. State the purpose in one sentence: <purpose>. <Key facts the AI needs to
44
+ convey or ask>. If you reach voicemail, leave a short message: <voicemail script>.
45
+ Stay polite and end the call once the objective is met.
46
+ ```
47
+
48
+ The task is verbatim instructions to the AI — every phrase you write WILL be spoken by the AI on the call. Don't include placeholders the AI can't resolve. Don't include private data the recipient shouldn't hear ("the user is buying a $40,000 car" is between Franklin and the user, not the recipient).
49
+
50
+ ### 4 · Confirm before firing
51
+
52
+ Show the user, in plain text:
53
+
54
+ - **To:** \`<recipient E.164>\`
55
+ - **From:** \`<caller-ID E.164>\` (\<days-left\> days on lease)
56
+ - **Cost:** $0.54 from wallet
57
+ - **Voice:** \`<preset>\` (default \`maya\`)
58
+ - **Max duration:** \<N\> minutes (default 5)
59
+ - **Task summary:** first 1–2 sentences
60
+
61
+ Ask: "Place the call? Reply \`yes\` to proceed." Wait for explicit confirmation. Anything other than yes → stop.
62
+
63
+ ### 5 · Place the call
64
+
65
+ ```
66
+ VoiceCall({
67
+ to: "<E.164>",
68
+ from: "<E.164 wallet-owned>",
69
+ task: "<full task script>",
70
+ voice: "<preset>", // optional, default maya
71
+ max_duration: <N>, // optional, default 5
72
+ language: "<code>" // optional, default en-US
73
+ })
74
+ ```
75
+
76
+ Tool returns a `call_id` immediately. Surface it to the user along with "polling now."
77
+
78
+ ### 6 · Auto-poll status
79
+
80
+ Loop, every ~30 seconds, calling `VoiceStatus({ call_id })`:
81
+
82
+ - If status is `queued` or `in_progress` → continue.
83
+ - If status is one of `completed` / `failed` / `cancelled` / `busy` / `no-answer` / `voicemail` → stop polling.
84
+ - Cap the loop at ~10 minutes total (20 polls). If you hit the cap, tell the user the call is still running and they can rerun `VoiceStatus call_id="…"` later.
85
+
86
+ VoiceStatus is **free** — poll as often as needed.
87
+
88
+ ### 7 · Surface the result
89
+
90
+ When polling ends:
91
+
92
+ - One-line outcome: status, duration (sec → MM:SS), disposition.
93
+ - Full transcript (or first 2000 chars + a note if longer).
94
+ - Recording URL if returned.
95
+ - Total cost ($0.54 — the polls were free).
96
+
97
+ If the call failed before connecting (busy, no-answer, voicemail without leaving a message), tell the user clearly. Don't dress up a failed call as a partial success.
98
+
99
+ ## Compliance — non-negotiable
100
+
101
+ - **US/CA destinations only.** Anything else, refuse.
102
+ - **Daytime preference.** Estimate the recipient's local time from the area code; if it's outside 9 am – 9 pm local, raise the question in the confirmation step (not after firing). User can override.
103
+ - **Marketing / sales calls require prior express consent.** If the task script reads like outbound marketing (selling something, soliciting sign-ups, promotional offers), refuse unless the user explicitly attests in their message that the recipient has prior consent. TCPA in the US is not a guideline; it's a statute with private right of action.
104
+ - **Don't auto-fire follow-ups.** If a call fails or ends in voicemail, ask the user whether to retry, don't loop automatically.
105
+
106
+ ## Anti-patterns
107
+
108
+ - Placing a call to a number the user didn't explicitly type. If they said "call them about Y", clarify who "them" is — don't grep history for the most-recent-looking number.
109
+ - Writing a task script that pressures the recipient (false urgency, fake authority, manipulation). The AI on the call WILL execute whatever's in the script.
110
+ - Sequential cold calls without consent. One call per skill invocation; if the user wants a sequence, ask them to confirm each one.
111
+ - Calling 911 or any emergency line. Both Franklin and BlockRun block this; don't try to test it.
112
+
113
+ ## The user said
114
+
115
+ $ARGUMENTS
@@ -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, chat), AI inference (chat / image / video / music), phone numbers and voice calls, ' +
178
- 'prediction markets, DeFi data, and any other API exposed under https://blockrun.ai/marketplace. ' +
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, /surf-chat document which endpoints to call for common workflows — read those when you are unsure which path serves the user\'s question. ' +
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',
@@ -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, Phone, future partners (see /surf-* skills)
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,352 @@
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
+ import { CallLog } from '../phone/call-log.js';
21
+ /** Singleton, lazy — paths are computed at first use so tests can stub homedir. */
22
+ let _callLog = null;
23
+ function callLog() {
24
+ if (!_callLog)
25
+ _callLog = new CallLog();
26
+ return _callLog;
27
+ }
28
+ /**
29
+ * Normalize whatever string Bland.ai returns as `status` (or `disposition` /
30
+ * `call_state` — the field name has drifted across upstream versions) into
31
+ * the CallStatus union our journal stores. Unknown / missing → 'queued' so
32
+ * the row still gets written and a later poll can refine it.
33
+ */
34
+ function normalizeStatus(raw) {
35
+ if (typeof raw !== 'string')
36
+ return 'queued';
37
+ const s = raw.toLowerCase().trim();
38
+ if (s === 'completed')
39
+ return 'completed';
40
+ if (s === 'failed' || s === 'error')
41
+ return 'failed';
42
+ if (s === 'cancelled' || s === 'canceled')
43
+ return 'cancelled';
44
+ if (s === 'busy')
45
+ return 'busy';
46
+ if (s === 'no-answer' || s === 'no_answer' || s === 'noanswer')
47
+ return 'no-answer';
48
+ if (s === 'voicemail')
49
+ return 'voicemail';
50
+ if (s === 'in-progress' || s === 'in_progress' || s === 'inprogress' || s === 'ringing')
51
+ return 'in_progress';
52
+ return 'queued';
53
+ }
54
+ const VOICE_TIMEOUT_MS = 30_000;
55
+ // ─── Shared x402 helpers (paid POST + free GET) ───────────────────────────
56
+ async function postWithPayment(path, body, ctx) {
57
+ const chain = loadChain();
58
+ const apiUrl = API_URLS[chain];
59
+ const endpoint = `${apiUrl}${path}`;
60
+ const bodyStr = JSON.stringify(body);
61
+ const headers = {
62
+ 'Content-Type': 'application/json',
63
+ 'User-Agent': `franklin/${VERSION}`,
64
+ };
65
+ const controller = new AbortController();
66
+ const timeout = setTimeout(() => controller.abort(), VOICE_TIMEOUT_MS);
67
+ const onAbort = () => controller.abort();
68
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
69
+ try {
70
+ let response = await fetch(endpoint, {
71
+ method: 'POST',
72
+ signal: controller.signal,
73
+ headers,
74
+ body: bodyStr,
75
+ });
76
+ if (response.status === 402) {
77
+ const paymentHeaders = await signPayment(response, chain, endpoint);
78
+ if (!paymentHeaders)
79
+ throw new Error('Payment signing failed — check wallet balance');
80
+ response = await fetch(endpoint, {
81
+ method: 'POST',
82
+ signal: controller.signal,
83
+ headers: { ...headers, ...paymentHeaders },
84
+ body: bodyStr,
85
+ });
86
+ }
87
+ if (!response.ok) {
88
+ const errText = await response.text().catch(() => '');
89
+ throw new Error(`Voice ${path} failed (${response.status}): ${errText.slice(0, 400)}`);
90
+ }
91
+ return (await response.json());
92
+ }
93
+ finally {
94
+ clearTimeout(timeout);
95
+ ctx.abortSignal.removeEventListener('abort', onAbort);
96
+ }
97
+ }
98
+ async function getNoPayment(path, ctx) {
99
+ const chain = loadChain();
100
+ const apiUrl = API_URLS[chain];
101
+ const endpoint = `${apiUrl}${path}`;
102
+ const controller = new AbortController();
103
+ const timeout = setTimeout(() => controller.abort(), VOICE_TIMEOUT_MS);
104
+ const onAbort = () => controller.abort();
105
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
106
+ try {
107
+ const resp = await fetch(endpoint, {
108
+ method: 'GET',
109
+ signal: controller.signal,
110
+ headers: { 'User-Agent': `franklin/${VERSION}` },
111
+ });
112
+ if (!resp.ok) {
113
+ const errText = await resp.text().catch(() => '');
114
+ throw new Error(`Voice ${path} failed (${resp.status}): ${errText.slice(0, 300)}`);
115
+ }
116
+ return (await resp.json());
117
+ }
118
+ finally {
119
+ clearTimeout(timeout);
120
+ ctx.abortSignal.removeEventListener('abort', onAbort);
121
+ }
122
+ }
123
+ async function signPayment(response, chain, endpoint) {
124
+ try {
125
+ const paymentHeader = await extractPaymentReq(response);
126
+ if (!paymentHeader)
127
+ return null;
128
+ if (chain === 'solana') {
129
+ const wallet = await getOrCreateSolanaWallet();
130
+ const paymentRequired = parsePaymentRequired(paymentHeader);
131
+ const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
132
+ const secretBytes = await solanaKeyToBytes(wallet.privateKey);
133
+ const feePayer = details.extra?.feePayer || details.recipient;
134
+ const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
135
+ resourceUrl: details.resource?.url || endpoint,
136
+ resourceDescription: details.resource?.description || 'Franklin voice call',
137
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
138
+ extra: details.extra,
139
+ });
140
+ return { 'PAYMENT-SIGNATURE': payload };
141
+ }
142
+ const wallet = getOrCreateWallet();
143
+ const paymentRequired = parsePaymentRequired(paymentHeader);
144
+ const details = extractPaymentDetails(paymentRequired);
145
+ const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
146
+ resourceUrl: details.resource?.url || endpoint,
147
+ resourceDescription: details.resource?.description || 'Franklin voice call',
148
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
149
+ extra: details.extra,
150
+ });
151
+ return { 'PAYMENT-SIGNATURE': payload };
152
+ }
153
+ catch (err) {
154
+ logger.warn(`[franklin] Voice payment error: ${err.message}`);
155
+ return null;
156
+ }
157
+ }
158
+ async function extractPaymentReq(response) {
159
+ let header = response.headers.get('payment-required');
160
+ if (!header) {
161
+ try {
162
+ const body = (await response.json());
163
+ if (body.x402 || body.accepts)
164
+ header = btoa(JSON.stringify(body));
165
+ }
166
+ catch { /* not JSON */ }
167
+ }
168
+ return header;
169
+ }
170
+ // ─── Tools ─────────────────────────────────────────────────────────────────
171
+ export const voiceCallCapability = {
172
+ spec: {
173
+ name: 'VoiceCall',
174
+ description: 'Make an outbound AI-powered phone call via Bland.ai. The AI agent on the other end ' +
175
+ 'follows the `task` description in natural language. Cost: $0.54 flat per call (up to 5 min ' +
176
+ 'default, 30 min max). Returns a call_id immediately; the call runs asynchronously. Use ' +
177
+ 'VoiceStatus with the same call_id to poll transcript / recording / disposition.\n\n' +
178
+ 'Common use cases: appointment reminders, verification callbacks, voice surveys, customer ' +
179
+ 'outreach, OTP retrieval, two-party verification calls.\n\n' +
180
+ 'Requirements:\n' +
181
+ ' - `from` MUST be a wallet-owned BlockRun phone number — use ListPhoneNumbers to find ' +
182
+ 'one or BuyPhoneNumber to provision one ($5, 30-day lease).\n' +
183
+ ' - `to` and `from` must be E.164 format (+ country code prefix, e.g. +14155552671).\n' +
184
+ ' - `task` must be ≥10 chars, ≤4000 chars.\n' +
185
+ ' - US/CA destinations only.',
186
+ input_schema: {
187
+ type: 'object',
188
+ properties: {
189
+ to: {
190
+ type: 'string',
191
+ description: 'Recipient phone number in E.164 format, e.g. +14155552671.',
192
+ },
193
+ from: {
194
+ type: 'string',
195
+ description: 'Caller ID — must be a phone number your wallet owns via BlockRun (provision with ' +
196
+ 'BuyPhoneNumber). E.164 format.',
197
+ },
198
+ task: {
199
+ type: 'string',
200
+ description: 'Natural-language description of what the AI should do on the call. Min 10 chars, ' +
201
+ 'max 4000. Example: "Greet the person, confirm their 3 pm appointment for Thursday, ' +
202
+ 'and ask if they need to reschedule. Speak warmly and end the call after confirmation."',
203
+ },
204
+ voice: {
205
+ type: 'string',
206
+ enum: ['nat', 'josh', 'maya', 'june', 'paige', 'derek', 'florian'],
207
+ description: 'Voice preset (default: maya). Try josh/derek for male voices, maya/june/paige for ' +
208
+ 'female, nat for neutral.',
209
+ },
210
+ max_duration: {
211
+ type: 'integer',
212
+ minimum: 1,
213
+ maximum: 30,
214
+ description: 'Maximum call length in minutes (1–30, default: 5).',
215
+ },
216
+ language: {
217
+ type: 'string',
218
+ description: 'Language code for STT/TTS (default: en-US). Bland supports zh-CN, es-ES, etc.',
219
+ },
220
+ first_sentence: {
221
+ type: 'string',
222
+ description: 'Optional fixed opening line spoken before the AI takes over (≤500 chars).',
223
+ },
224
+ wait_for_greeting: {
225
+ type: 'boolean',
226
+ description: 'If true, AI waits for the recipient to speak first before talking.',
227
+ },
228
+ },
229
+ required: ['to', 'from', 'task'],
230
+ },
231
+ },
232
+ execute: async (input, ctx) => {
233
+ if (typeof input.to !== 'string')
234
+ return { output: 'to (E.164) required', isError: true };
235
+ if (typeof input.from !== 'string')
236
+ return { output: 'from (wallet-owned E.164) required — use ListPhoneNumbers / BuyPhoneNumber', isError: true };
237
+ if (typeof input.task !== 'string' || input.task.length < 10) {
238
+ return { output: 'task required (10–4000 chars natural-language description)', isError: true };
239
+ }
240
+ const body = {
241
+ to: input.to,
242
+ from: input.from,
243
+ task: input.task,
244
+ };
245
+ // The gateway validates additionalProperties: false — only forward known
246
+ // optional fields, don't echo back whatever the caller passed.
247
+ if (typeof input.voice === 'string')
248
+ body.voice = input.voice;
249
+ if (typeof input.max_duration === 'number')
250
+ body.max_duration = input.max_duration;
251
+ if (typeof input.language === 'string')
252
+ body.language = input.language;
253
+ if (typeof input.first_sentence === 'string')
254
+ body.first_sentence = input.first_sentence;
255
+ if (typeof input.wait_for_greeting === 'boolean')
256
+ body.wait_for_greeting = input.wait_for_greeting;
257
+ try {
258
+ const res = await postWithPayment('/v1/voice/call', body, ctx);
259
+ const callId = (res.call_id || res.id);
260
+ // Persist a "queued" row so the panel sees the call before VoiceStatus polls.
261
+ // Best-effort — if disk write fails we still surface the call_id to the agent.
262
+ if (callId) {
263
+ try {
264
+ callLog().append({
265
+ timestamp: Date.now(),
266
+ call_id: callId,
267
+ to: String(input.to),
268
+ from: String(input.from),
269
+ task: String(input.task),
270
+ voice: typeof input.voice === 'string' ? input.voice : undefined,
271
+ max_duration_min: typeof input.max_duration === 'number' ? input.max_duration : undefined,
272
+ language: typeof input.language === 'string' ? input.language : undefined,
273
+ status: normalizeStatus(res.status),
274
+ paid_usd: 0.54,
275
+ tx_hash: typeof res.tx_hash === 'string' ? res.tx_hash : undefined,
276
+ });
277
+ }
278
+ catch { /* best-effort */ }
279
+ }
280
+ return {
281
+ output: `## Voice call initiated ($0.54 USDC charged)\n\n` +
282
+ (callId
283
+ ? `**call_id:** \`${callId}\`\n\nPoll with VoiceStatus call_id="${callId}" to get the ` +
284
+ `transcript and disposition. The call typically completes in 1–6 minutes.\n\n`
285
+ : '') +
286
+ '```json\n' + JSON.stringify(res, null, 2) + '\n```',
287
+ };
288
+ }
289
+ catch (err) {
290
+ return { output: `Voice call failed: ${err.message}`, isError: true };
291
+ }
292
+ },
293
+ };
294
+ export const voiceStatusCapability = {
295
+ spec: {
296
+ name: 'VoiceStatus',
297
+ description: 'Poll a previously-initiated voice call for its current status, transcript, recording URL, ' +
298
+ 'and final disposition (completed / failed / no-answer / busy / voicemail). Free — no USDC ' +
299
+ 'charged. Use the call_id returned by VoiceCall. Call this every 30–60 s until status is ' +
300
+ 'a terminal state.',
301
+ input_schema: {
302
+ type: 'object',
303
+ properties: {
304
+ call_id: {
305
+ type: 'string',
306
+ description: 'The call_id returned by a prior VoiceCall.',
307
+ },
308
+ },
309
+ required: ['call_id'],
310
+ },
311
+ },
312
+ execute: async (input, ctx) => {
313
+ if (typeof input.call_id !== 'string') {
314
+ return { output: 'call_id required', isError: true };
315
+ }
316
+ try {
317
+ const res = await getNoPayment(`/v1/voice/call/${encodeURIComponent(input.call_id)}`, ctx);
318
+ // Patch the local journal with whatever fields the gateway returned —
319
+ // transcript / recording / duration / disposition. Append-only schema
320
+ // means we just write a new row; CallLog.summary() picks the latest.
321
+ try {
322
+ const prior = callLog().byCallId(input.call_id);
323
+ if (prior) {
324
+ const recording = typeof res.recording_url === 'string' ? res.recording_url :
325
+ typeof res.recording === 'string' ? res.recording : undefined;
326
+ const duration = typeof res.call_length === 'number' ? Math.round(res.call_length) :
327
+ typeof res.duration === 'number' ? Math.round(res.duration) :
328
+ typeof res.duration_sec === 'number' ? Math.round(res.duration_sec) : undefined;
329
+ const transcript = typeof res.concatenated_transcript === 'string' ? res.concatenated_transcript :
330
+ typeof res.transcript === 'string' ? res.transcript : undefined;
331
+ callLog().append({
332
+ ...prior,
333
+ timestamp: Date.now(),
334
+ paid_usd: 0, // status polls are free; only the initial POST charges
335
+ status: normalizeStatus(res.status ?? res.queue_status ?? res.disposition),
336
+ duration_sec: duration ?? prior.duration_sec,
337
+ transcript: transcript ?? prior.transcript,
338
+ recording_url: recording ?? prior.recording_url,
339
+ });
340
+ }
341
+ }
342
+ catch { /* best-effort */ }
343
+ return {
344
+ output: `## Voice call status\n\n` +
345
+ '```json\n' + JSON.stringify(res, null, 2) + '\n```',
346
+ };
347
+ }
348
+ catch (err) {
349
+ return { output: `VoiceStatus failed: ${err.message}`, isError: true };
350
+ }
351
+ },
352
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.20.1",
3
+ "version": "3.21.0",
4
4
  "description": "Franklin Agent — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {