@blockrun/franklin 3.20.2 → 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).
@@ -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
@@ -17,6 +17,40 @@
17
17
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
18
18
  import { loadChain, API_URLS, VERSION } from '../config.js';
19
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
+ }
20
54
  const VOICE_TIMEOUT_MS = 30_000;
21
55
  // ─── Shared x402 helpers (paid POST + free GET) ───────────────────────────
22
56
  async function postWithPayment(path, body, ctx) {
@@ -223,6 +257,26 @@ export const voiceCallCapability = {
223
257
  try {
224
258
  const res = await postWithPayment('/v1/voice/call', body, ctx);
225
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
+ }
226
280
  return {
227
281
  output: `## Voice call initiated ($0.54 USDC charged)\n\n` +
228
282
  (callId
@@ -261,6 +315,31 @@ export const voiceStatusCapability = {
261
315
  }
262
316
  try {
263
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 */ }
264
343
  return {
265
344
  output: `## Voice call status\n\n` +
266
345
  '```json\n' + JSON.stringify(res, null, 2) + '\n```',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.20.2",
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": {