From c00037e864cbad267ebe3e67eff902d6fb1374a2 Mon Sep 17 00:00:00 2001 From: Celeste Ang Date: Fri, 15 May 2026 16:25:22 +0800 Subject: [PATCH 1/2] feat: equip ACP agents with phone numbers via AgentPhone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `acp phone` command tree wiring the active ACP agent to an AgentPhone persona (https://agentphone.ai) so it can own SMS- and voice-enabled numbers, send texts, place AI-handled outbound calls, and read call transcripts. Direct client to api.agentphone.ai with retry on 429/5xx; key sourced from AGENTPHONE_API_KEY env var only (no keychain storage yet). Local ACP-agent → AgentPhone-agent mapping persisted in \$ACP_CONFIG_DIR/phone.json so the persona is created lazily on first `acp phone provision`. Subcommands: whoami, provision, numbers, attach, sms send|inbox|thread, call, calls list|transcript. Destructive `release` and webhook secret management deliberately deferred to follow-ups. Co-Authored-By: Claude Opus 4.7 --- README.md | 48 +++- bin/acp.ts | 2 + package.json | 2 +- src/commands/phone.ts | 591 ++++++++++++++++++++++++++++++++++++++ src/lib/api/agentphone.ts | 328 +++++++++++++++++++++ src/lib/phoneConfig.ts | 57 ++++ 6 files changed, 1024 insertions(+), 4 deletions(-) create mode 100644 src/commands/phone.ts create mode 100644 src/lib/api/agentphone.ts create mode 100644 src/lib/phoneConfig.ts diff --git a/README.md b/README.md index 78a99bc..a1297d5 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,11 @@ All environment variables are optional. The CLI works out of the box after `acp | Variable | Default | Description | | ---------------- | ---------------- | ---------------------------------------------------------------------------- | -| `IS_TESTNET` | — | Set to `true` to use testnet chains, API server, and Privy app | -| `PARTNER_ID` | — | Partner ID for tokenization | -| `ACP_CONFIG_DIR` | `~/.config/acp` | Directory holding the config file(s). The filename is picked per-env (below) | +| `IS_TESTNET` | — | Set to `true` to use testnet chains, API server, and Privy app | +| `PARTNER_ID` | — | Partner ID for tokenization | +| `ACP_CONFIG_DIR` | `~/.config/acp` | Directory holding the config file(s). The filename is picked per-env (below) | +| `AGENTPHONE_API_KEY` | — | API key for [AgentPhone](https://agentphone.ai). Required for any `acp phone …` command | +| `AGENTPHONE_BASE_URL`| `https://api.agentphone.ai` | Override the AgentPhone API base URL (for self-hosted or staging environments) | Mainnet and testnet store state separately so identities don't mix when toggling `IS_TESTNET`: @@ -481,6 +483,46 @@ acp card get --request-id # detail for one > **PAN/CVV is shown exactly once** — on `card issue`. There is no way to > re-fetch unmasked details later. Store them immediately. +### Agent Phone Numbers + +Equip the active ACP agent with a real phone number via [AgentPhone](https://agentphone.ai) — provision SMS- and voice-enabled numbers, send texts, place AI-handled calls, and read transcripts. + +Requires `AGENTPHONE_API_KEY` in the environment (get one at [agentphone.ai](https://agentphone.ai); new accounts get $5 free credit). The CLI talks to `api.agentphone.ai` directly — keys do not leave the local machine. + +```bash +# Show the AgentPhone persona linked to the active agent + attached numbers +acp phone whoami + +# Buy a US number in the 415 area code and bind it to the active agent. +# On first run, this also creates an AgentPhone persona for the agent and saves +# the mapping under $ACP_CONFIG_DIR/phone.json. +acp phone provision \ + --area-code 415 \ + --voice-mode webhook \ + --webhook-url https://your-server.example/agentphone + +# Hosted-LLM mode (no webhook needed) +acp phone provision --voice-mode hosted --system-prompt "You are a friendly support agent." + +# List numbers + attach an existing one +acp phone numbers +acp phone attach num_xyz789 + +# SMS +acp phone sms send --to +14155551234 --body "Hello from acp-cli" +acp phone sms inbox +acp phone sms thread + +# Voice +acp phone call --to +14155551234 --greeting "Hi, calling on behalf of MyAgent." +acp phone calls list +acp phone calls transcript +``` + +> Pricing surfaced before `provision`: **$3.00/month per number** plus per-minute SMS/voice usage. Pass `--yes` to skip the confirm prompt in non-interactive flows. +> +> Releasing numbers is intentionally not yet a CLI command — use the AgentPhone dashboard until a `release` subcommand with double-confirm lands. + ### Chain Info ```bash diff --git a/bin/acp.ts b/bin/acp.ts index b0eae0f..544a709 100755 --- a/bin/acp.ts +++ b/bin/acp.ts @@ -17,6 +17,7 @@ import { registerSubscriptionCommands } from "../src/commands/subscription"; import { registerChainCommands } from "../src/commands/chain"; import { registerEmailCommands } from "../src/commands/email"; import { registerCardCommands } from "../src/commands/card"; +import { registerPhoneCommands } from "../src/commands/phone"; const require = createRequire(import.meta.url); @@ -57,5 +58,6 @@ registerSubscriptionCommands(program); registerChainCommands(program); registerEmailCommands(program); registerCardCommands(program); +registerPhoneCommands(program); program.parse(); diff --git a/package.json b/package.json index e89f2d9..2bdcd03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@virtuals-protocol/acp-cli", - "version": "1.0.7", + "version": "1.0.8", "type": "module", "bin": { "acp": "./dist/bin/acp.js" diff --git a/src/commands/phone.ts b/src/commands/phone.ts new file mode 100644 index 0000000..7aa597b --- /dev/null +++ b/src/commands/phone.ts @@ -0,0 +1,591 @@ +import * as readline from "readline"; +import type { Command } from "commander"; +import { + isJson, + outputResult, + outputError, + formatDate, +} from "../lib/output"; +import { c } from "../lib/color"; +import { getClient } from "../lib/api/client"; +import { prompt, printTable } from "../lib/prompt"; +import { getActiveAgentId } from "../lib/activeAgent"; +import { CliError } from "../lib/errors"; +import { + AgentPhoneClient, + type AgentPhoneAgent, + type AgentPhoneCall, + type AgentPhoneConversation, + type AgentPhoneMessage, + type AgentPhoneNumber, + type AgentPhoneTranscript, +} from "../lib/api/agentphone"; +import { + getPhoneMapping, + setPhoneMapping, + type PhoneMapping, +} from "../lib/phoneConfig"; + +function newClient(): AgentPhoneClient { + return new AgentPhoneClient(); +} + +function reportError(json: boolean, err: unknown): void { + if (err instanceof Error) outputError(json, err); + else outputError(json, String(err)); +} + +async function resolveAgentphoneAgentId( + ap: AgentPhoneClient, + acpAgentId: string, + opts: { + voiceMode: "webhook" | "hosted"; + webhookUrl?: string; + systemPrompt?: string; + } +): Promise<{ agentphoneAgentId: string; mapping: PhoneMapping }> { + const existing = getPhoneMapping(acpAgentId); + if (existing) { + return { agentphoneAgentId: existing.agentphoneAgentId, mapping: existing }; + } + + // Lazy-create: look up the ACP agent's name to use as the AgentPhone + // persona name, so the personas are recognizable in the AgentPhone + // dashboard. + const { agentApi } = await getClient(); + const acpAgent = await agentApi.getById(acpAgentId); + + const created = await ap.createAgent({ + name: acpAgent.name, + voiceMode: opts.voiceMode, + webhookUrl: opts.webhookUrl, + systemPrompt: opts.systemPrompt, + }); + + const mapping: PhoneMapping = { + agentphoneAgentId: created.id, + voiceMode: opts.voiceMode, + webhookUrl: opts.webhookUrl, + createdAt: new Date().toISOString(), + }; + setPhoneMapping(acpAgentId, mapping); + return { agentphoneAgentId: created.id, mapping }; +} + +function printNumber(n: AgentPhoneNumber): void { + printTable([ + ["Number ID", n.id], + ["E.164", n.phoneNumber], + ["Country", n.country ?? "—"], + ["Status", n.status ?? "—"], + ["Attached agent", n.agentId ?? "—"], + ["Created", n.createdAt ? formatDate(n.createdAt) : "—"], + ]); +} + +function printConversation(conv: AgentPhoneConversation): void { + printTable([ + ["ID", conv.id], + ["Participant", conv.participant ?? "—"], + ["Messages", conv.messageCount !== undefined ? String(conv.messageCount) : "—"], + ["Last msg", conv.lastMessageAt ? formatDate(conv.lastMessageAt) : "—"], + ["Preview", conv.preview ?? "—"], + ]); +} + +function printMessage(msg: AgentPhoneMessage): void { + const dir = + msg.direction === "inbound" + ? c.cyan("IN") + : msg.direction === "outbound" + ? c.yellow("OUT") + : msg.direction ?? "—"; + console.log(`${dir} ${c.dim(msg.createdAt ? formatDate(msg.createdAt) : "")}`); + console.log(` From: ${msg.from ?? "—"}`); + console.log(` To: ${msg.to ?? "—"}`); + console.log(` ${msg.body ?? ""}`); +} + +function printCall(call: AgentPhoneCall): void { + printTable([ + ["Call ID", call.id], + ["From", call.fromNumber ?? "—"], + ["To", call.toNumber ?? "—"], + ["Status", call.status ?? "—"], + ["Duration", call.durationSeconds !== undefined ? `${call.durationSeconds}s` : "—"], + ["Created", call.createdAt ? formatDate(call.createdAt) : "—"], + ]); +} + +function printTranscript(t: AgentPhoneTranscript): void { + if (!t.turns?.length) { + console.log("No transcript turns."); + return; + } + for (const turn of t.turns) { + const label = + turn.role === "user" + ? c.cyan("CALLER") + : turn.role === "agent" + ? c.yellow("AGENT") + : turn.role.toUpperCase(); + const at = turn.at ? c.dim(` ${formatDate(turn.at)}`) : ""; + console.log(`${label}${at}`); + console.log(` ${turn.text}`); + } +} + +async function confirmYes(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = (await prompt(rl, `${message} [y/N] `)).trim().toLowerCase(); + return answer === "y" || answer === "yes"; + } finally { + rl.close(); + } +} + +export function registerPhoneCommands(program: Command): void { + const phone = program + .command("phone") + .description( + "Manage AgentPhone (https://agentphone.ai) numbers, SMS, and voice calls for the active agent. Requires AGENTPHONE_API_KEY." + ); + + // WHOAMI + phone + .command("whoami") + .description("Show the AgentPhone persona linked to the active ACP agent") + .action(async (_opts, cmd) => { + const json = isJson(cmd); + const acpAgentId = getActiveAgentId(json); + if (!acpAgentId) return; + + const mapping = getPhoneMapping(acpAgentId); + if (!mapping) { + if (json) { + outputResult(json, { linked: false, acpAgentId }); + } else { + console.log( + `No AgentPhone persona linked to this agent yet. Run ${c.cyan("acp phone provision")} to create one.` + ); + } + return; + } + + try { + const ap = newClient(); + const persona = await ap.getAgent(mapping.agentphoneAgentId); + if (json) { + outputResult(json, { + linked: true, + acpAgentId, + mapping, + persona: persona as unknown as Record, + }); + } else { + printTable([ + ["ACP agent", acpAgentId], + ["AgentPhone agent", persona.id], + ["Name", persona.name], + ["Voice mode", persona.voiceMode], + ["Webhook URL", persona.webhookUrl ?? "—"], + ["Linked at", formatDate(mapping.createdAt)], + ]); + if (persona.numbers && persona.numbers.length > 0) { + console.log("\nAttached numbers:"); + for (const n of persona.numbers) { + printNumber(n); + console.log(); + } + } else { + console.log(`\nNo numbers attached. Run ${c.cyan("acp phone provision")}.`); + } + } + } catch (err) { + reportError(json, err); + } + }); + + // PROVISION + phone + .command("provision") + .description( + "Buy a phone number and attach it to the active ACP agent. Creates the AgentPhone persona on first use." + ) + .option("--area-code ", "Preferred area code (e.g. 415)") + .option("--country ", "ISO country code", "US") + .option( + "--voice-mode ", + "Voice mode: webhook | hosted (default: webhook)", + "webhook" + ) + .option( + "--webhook-url ", + "Webhook URL to receive call transcripts (required for voice-mode=webhook)" + ) + .option( + "--system-prompt ", + "System prompt (used when voice-mode=hosted)" + ) + .option("--yes", "Skip the monthly-cost confirmation prompt") + .action(async (opts, cmd) => { + const json = isJson(cmd); + const acpAgentId = getActiveAgentId(json); + if (!acpAgentId) return; + + const voiceMode = opts.voiceMode as "webhook" | "hosted"; + if (voiceMode !== "webhook" && voiceMode !== "hosted") { + outputError( + json, + new CliError( + `Invalid --voice-mode: ${opts.voiceMode}`, + "VALIDATION_ERROR", + "Use either 'webhook' or 'hosted'." + ) + ); + return; + } + if (voiceMode === "webhook" && !opts.webhookUrl && !getPhoneMapping(acpAgentId)?.webhookUrl) { + outputError( + json, + new CliError( + "--webhook-url is required for voice-mode=webhook on first provision.", + "VALIDATION_ERROR", + "Pass --webhook-url , or use --voice-mode hosted." + ) + ); + return; + } + + if (!opts.yes && !json) { + const ok = await confirmYes( + "Provisioning a phone number costs $3.00/month plus per-minute SMS/voice usage. Continue?" + ); + if (!ok) { + console.log("Aborted."); + return; + } + } + + try { + const ap = newClient(); + const { agentphoneAgentId } = await resolveAgentphoneAgentId( + ap, + acpAgentId, + { + voiceMode, + webhookUrl: opts.webhookUrl, + systemPrompt: opts.systemPrompt, + } + ); + + const number = await ap.createNumber({ + country: opts.country, + areaCode: opts.areaCode, + agentId: agentphoneAgentId, + }); + + if (json) { + outputResult(json, { + acpAgentId, + agentphoneAgentId, + number: number as unknown as Record, + }); + } else { + console.log( + `\n${c.green("Phone number provisioned!")} ${c.cyan(number.phoneNumber)}` + ); + printNumber(number); + } + } catch (err) { + reportError(json, err); + } + }); + + // NUMBERS + phone + .command("numbers") + .description("List all phone numbers on the AgentPhone account") + .option("--limit ", "Page size (1-100)") + .option("--offset ", "Offset (default 0)") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const ap = newClient(); + const numbers = await ap.listNumbers({ + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + }); + if (json) { + process.stdout.write(JSON.stringify({ numbers }) + "\n"); + return; + } + if (numbers.length === 0) { + console.log("No numbers on this account."); + return; + } + for (const n of numbers) { + printNumber(n); + console.log(); + } + } catch (err) { + reportError(json, err); + } + }); + + // ATTACH + phone + .command("attach") + .description("Attach an existing AgentPhone number to the active ACP agent") + .argument("", "Number ID (e.g. num_xyz...)") + .action(async (numberId: string, _opts, cmd) => { + const json = isJson(cmd); + const acpAgentId = getActiveAgentId(json); + if (!acpAgentId) return; + + try { + const ap = newClient(); + const { agentphoneAgentId } = await resolveAgentphoneAgentId( + ap, + acpAgentId, + { voiceMode: "webhook" } + ); + const result = await ap.attachNumber(agentphoneAgentId, numberId); + if (json) { + outputResult(json, result as unknown as Record); + } else { + console.log(`${c.green("Attached.")}`); + printNumber(result); + } + } catch (err) { + reportError(json, err); + } + }); + + // SMS group + const sms = phone.command("sms").description("SMS subcommands"); + + sms + .command("send") + .description("Send an SMS from one of the active agent's numbers") + .requiredOption("--to ", "Recipient phone number (E.164)") + .requiredOption("--body ", "Message body") + .option( + "--from ", + "Sender E.164 (defaults to the first attached number)" + ) + .action(async (opts, cmd) => { + const json = isJson(cmd); + const acpAgentId = getActiveAgentId(json); + if (!acpAgentId) return; + + const mapping = getPhoneMapping(acpAgentId); + if (!mapping) { + outputError( + json, + new CliError( + "No AgentPhone persona linked.", + "NO_ACTIVE_AGENT", + "Run `acp phone provision` first." + ) + ); + return; + } + + try { + const ap = newClient(); + let from: string | undefined = opts.from; + if (!from) { + const persona = await ap.getAgent(mapping.agentphoneAgentId); + from = persona.numbers?.[0]?.phoneNumber; + if (!from) { + outputError( + json, + new CliError( + "No numbers attached to this agent.", + "VALIDATION_ERROR", + "Run `acp phone provision` or pass --from." + ) + ); + return; + } + } + const result = await ap.sendMessage({ + from, + to: opts.to, + body: opts.body, + }); + if (json) { + outputResult(json, result as unknown as Record); + } else { + console.log(`${c.green("Sent.")} Message ID: ${result.id}`); + } + } catch (err) { + reportError(json, err); + } + }); + + sms + .command("inbox") + .description("List SMS conversations on this AgentPhone account") + .option("--limit ", "Page size", "20") + .option("--offset ", "Offset", "0") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const ap = newClient(); + const convs = await ap.listConversations({ + limit: parseInt(opts.limit, 10), + offset: parseInt(opts.offset, 10), + }); + if (json) { + process.stdout.write(JSON.stringify({ conversations: convs }) + "\n"); + return; + } + if (convs.length === 0) { + console.log("No conversations."); + return; + } + for (const conv of convs) { + printConversation(conv); + console.log(); + } + } catch (err) { + reportError(json, err); + } + }); + + sms + .command("thread") + .description("Show messages in a conversation") + .argument("", "Conversation ID") + .option("--limit ", "Page size", "50") + .option("--offset ", "Offset", "0") + .action(async (conversationId: string, opts, cmd) => { + const json = isJson(cmd); + try { + const ap = newClient(); + const messages = await ap.listMessages(conversationId, { + limit: parseInt(opts.limit, 10), + offset: parseInt(opts.offset, 10), + }); + if (json) { + process.stdout.write(JSON.stringify({ messages }) + "\n"); + return; + } + if (messages.length === 0) { + console.log("No messages."); + return; + } + for (const msg of messages) { + printMessage(msg); + console.log(); + } + } catch (err) { + reportError(json, err); + } + }); + + // CALL (singular: initiate) + phone + .command("call") + .description("Place an outbound voice call from the active agent") + .requiredOption("--to ", "Destination number (E.164)") + .option( + "--from-number-id ", + "Source number ID (defaults to the agent's first number)" + ) + .option("--greeting ", "Initial spoken greeting") + .action(async (opts, cmd) => { + const json = isJson(cmd); + const acpAgentId = getActiveAgentId(json); + if (!acpAgentId) return; + + const mapping = getPhoneMapping(acpAgentId); + if (!mapping) { + outputError( + json, + new CliError( + "No AgentPhone persona linked.", + "NO_ACTIVE_AGENT", + "Run `acp phone provision` first." + ) + ); + return; + } + + try { + const ap = newClient(); + const call = await ap.createCall({ + agentId: mapping.agentphoneAgentId, + toNumber: opts.to, + fromNumberId: opts.fromNumberId, + greeting: opts.greeting, + }); + if (json) { + outputResult(json, call as unknown as Record); + } else { + console.log(`${c.green("Call initiated.")}`); + printCall(call); + } + } catch (err) { + reportError(json, err); + } + }); + + // CALLS group (plural: list / transcript) + const calls = phone.command("calls").description("Call history subcommands"); + + calls + .command("list") + .description("List recent calls") + .option("--limit ", "Page size", "20") + .option("--offset ", "Offset", "0") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const ap = newClient(); + const list = await ap.listCalls({ + limit: parseInt(opts.limit, 10), + offset: parseInt(opts.offset, 10), + }); + if (json) { + process.stdout.write(JSON.stringify({ calls: list }) + "\n"); + return; + } + if (list.length === 0) { + console.log("No calls."); + return; + } + for (const call of list) { + printCall(call); + console.log(); + } + } catch (err) { + reportError(json, err); + } + }); + + calls + .command("transcript") + .description("Fetch the full transcript for a completed call") + .argument("", "Call ID") + .action(async (callId: string, _opts, cmd) => { + const json = isJson(cmd); + try { + const ap = newClient(); + const transcript = await ap.getTranscript(callId); + if (json) { + process.stdout.write(JSON.stringify(transcript) + "\n"); + return; + } + printTranscript(transcript); + } catch (err) { + reportError(json, err); + } + }); +} diff --git a/src/lib/api/agentphone.ts b/src/lib/api/agentphone.ts new file mode 100644 index 0000000..33d3441 --- /dev/null +++ b/src/lib/api/agentphone.ts @@ -0,0 +1,328 @@ +import { CliError } from "../errors"; + +const DEFAULT_BASE_URL = "https://api.agentphone.ai"; +const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]); + +export interface AgentPhoneAgent { + id: string; + name: string; + description?: string | null; + voiceMode: "webhook" | "hosted"; + systemPrompt?: string | null; + voice?: string | null; + webhookUrl?: string | null; + numbers?: AgentPhoneNumber[]; + createdAt?: string; +} + +export interface AgentPhoneNumber { + id: string; + phoneNumber: string; + country?: string; + agentId?: string | null; + status?: string; + createdAt?: string; +} + +export interface AgentPhoneMessage { + id: string; + conversationId?: string; + direction?: "inbound" | "outbound"; + from?: string; + to?: string; + body?: string; + createdAt?: string; +} + +export interface AgentPhoneConversation { + id: string; + participant?: string; + numberId?: string; + lastMessageAt?: string; + messageCount?: number; + preview?: string; +} + +export interface AgentPhoneCall { + id: string; + agentId?: string; + fromNumber?: string; + toNumber?: string; + status?: string; + durationSeconds?: number; + createdAt?: string; +} + +export interface AgentPhoneTranscriptTurn { + role: "user" | "agent" | string; + text: string; + at?: string; +} + +export interface AgentPhoneTranscript { + callId: string; + turns: AgentPhoneTranscriptTurn[]; +} + +interface ListEnvelope { + data?: T[]; + items?: T[]; + total?: number; + hasMore?: boolean; +} + +export class AgentPhoneError extends Error { + status: number; + details: unknown; + constructor(message: string, status: number, details: unknown) { + super(message); + this.status = status; + this.details = details; + } +} + +export class AgentPhoneClient { + private baseUrl: string; + private apiKey: string; + + constructor(opts?: { apiKey?: string; baseUrl?: string }) { + const apiKey = opts?.apiKey ?? process.env.AGENTPHONE_API_KEY; + if (!apiKey) { + throw new CliError( + "AGENTPHONE_API_KEY not set.", + "NOT_AUTHENTICATED", + "Get a key at https://agentphone.ai and run `export AGENTPHONE_API_KEY=ap_...`." + ); + } + this.apiKey = apiKey; + this.baseUrl = + opts?.baseUrl ?? process.env.AGENTPHONE_BASE_URL ?? DEFAULT_BASE_URL; + } + + private async request( + method: string, + path: string, + body?: unknown + ): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + Authorization: `Bearer ${this.apiKey}`, + Accept: "application/json", + }; + let payload: string | undefined; + if (body !== undefined) { + payload = JSON.stringify(body); + headers["Content-Type"] = "application/json"; + } + + let lastErr: unknown; + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + let res: Response; + try { + res = await fetch(url, { method, headers, body: payload }); + } catch (err) { + lastErr = err; + if (attempt < maxAttempts) { + await sleep(backoffMs(attempt)); + continue; + } + throw err; + } + + if (res.ok) { + if (res.status === 204) return undefined as T; + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) return (await res.json()) as T; + return (await res.text()) as unknown as T; + } + + if (RETRY_STATUSES.has(res.status) && attempt < maxAttempts) { + await sleep(backoffMs(attempt, res.headers.get("retry-after"))); + continue; + } + + const errBody = await safeJson(res); + throw new AgentPhoneError( + formatErrorMessage(res.status, errBody), + res.status, + errBody + ); + } + throw lastErr ?? new Error("AgentPhone request failed"); + } + + // ---------- Agents ---------- + createAgent(body: { + name: string; + description?: string; + voiceMode?: "webhook" | "hosted"; + systemPrompt?: string; + voice?: string; + webhookUrl?: string; + }): Promise { + return this.request("POST", "/v1/agents", body); + } + + getAgent(agentId: string): Promise { + return this.request("GET", `/v1/agents/${agentId}`); + } + + updateAgent( + agentId: string, + body: Partial<{ + name: string; + description: string; + voiceMode: "webhook" | "hosted"; + systemPrompt: string; + voice: string; + webhookUrl: string; + }> + ): Promise { + return this.request("PATCH", `/v1/agents/${agentId}`, body); + } + + // ---------- Numbers ---------- + createNumber(body: { + country?: string; + areaCode?: string; + agentId?: string; + }): Promise { + return this.request("POST", "/v1/numbers", body); + } + + attachNumber( + agentId: string, + numberId: string + ): Promise { + return this.request("POST", `/v1/agents/${agentId}/numbers`, { + numberId, + }); + } + + async listNumbers(opts?: { + limit?: number; + offset?: number; + }): Promise { + const qs = buildQs(opts); + const res = await this.request>( + "GET", + `/v1/numbers${qs}` + ); + return res.data ?? res.items ?? []; + } + + // ---------- Messages / conversations ---------- + sendMessage(body: { + from: string; + to: string; + body: string; + mediaUrls?: string[]; + }): Promise { + // AgentPhone API uses snake_case for media_urls. + const payload: Record = { + from: body.from, + to: body.to, + body: body.body, + }; + if (body.mediaUrls) payload.media_urls = body.mediaUrls; + return this.request("POST", "/v1/messages", payload); + } + + async listConversations(opts?: { + limit?: number; + offset?: number; + }): Promise { + const qs = buildQs(opts); + const res = await this.request>( + "GET", + `/v1/conversations${qs}` + ); + return res.data ?? res.items ?? []; + } + + async listMessages( + conversationId: string, + opts?: { limit?: number; offset?: number } + ): Promise { + const qs = buildQs(opts); + const res = await this.request>( + "GET", + `/v1/conversations/${conversationId}/messages${qs}` + ); + return res.data ?? res.items ?? []; + } + + // ---------- Calls ---------- + createCall(body: { + agentId: string; + toNumber: string; + fromNumberId?: string; + greeting?: string; + }): Promise { + return this.request("POST", "/v1/calls", body); + } + + async listCalls(opts?: { + limit?: number; + offset?: number; + }): Promise { + const qs = buildQs(opts); + const res = await this.request>( + "GET", + `/v1/calls${qs}` + ); + return res.data ?? res.items ?? []; + } + + getTranscript(callId: string): Promise { + return this.request("GET", `/v1/calls/${callId}/transcript`); + } +} + +function buildQs(opts?: { limit?: number; offset?: number }): string { + if (!opts) return ""; + const parts: string[] = []; + if (opts.limit !== undefined) parts.push(`limit=${opts.limit}`); + if (opts.offset !== undefined) parts.push(`offset=${opts.offset}`); + return parts.length ? `?${parts.join("&")}` : ""; +} + +function backoffMs(attempt: number, retryAfter?: string | null): number { + if (retryAfter) { + const n = Number(retryAfter); + if (Number.isFinite(n) && n > 0) return Math.min(n * 1000, 5000); + } + return Math.min(500 * 2 ** (attempt - 1), 2000); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function safeJson(res: Response): Promise { + try { + return await res.json(); + } catch { + try { + return await res.text(); + } catch { + return null; + } + } +} + +function formatErrorMessage(status: number, body: unknown): string { + if (body && typeof body === "object") { + const b = body as Record; + if (typeof b.errorMessage === "string") return b.errorMessage; + if (typeof b.message === "string") return b.message; + if (Array.isArray(b.details) && b.details.length > 0) { + const first = b.details[0] as Record; + if (typeof first.msg === "string") return first.msg; + } + if (typeof b.detail === "string") return b.detail; + } + if (typeof body === "string" && body.length > 0) return body; + return `AgentPhone API error (HTTP ${status})`; +} diff --git a/src/lib/phoneConfig.ts b/src/lib/phoneConfig.ts new file mode 100644 index 0000000..09df384 --- /dev/null +++ b/src/lib/phoneConfig.ts @@ -0,0 +1,57 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { resolve } from "path"; + +const IS_TESTNET = process.env.IS_TESTNET === "true"; + +const CONFIG_DIR = process.env.ACP_CONFIG_DIR + ? resolve(process.env.ACP_CONFIG_DIR) + : resolve(homedir(), ".config", "acp"); +const PHONE_FILENAME = IS_TESTNET ? "phone-testnet.json" : "phone.json"; +const PHONE_PATH = resolve(CONFIG_DIR, PHONE_FILENAME); + +export interface PhoneMapping { + agentphoneAgentId: string; + voiceMode: "webhook" | "hosted"; + webhookUrl?: string; + createdAt: string; +} + +interface PhoneStore { + agents?: Record; +} + +function load(): PhoneStore { + if (!existsSync(PHONE_PATH)) return {}; + try { + return JSON.parse(readFileSync(PHONE_PATH, "utf8")) as PhoneStore; + } catch { + return {}; + } +} + +function save(store: PhoneStore): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(PHONE_PATH, JSON.stringify(store, null, 2) + "\n"); +} + +export function getPhoneMapping(acpAgentId: string): PhoneMapping | null { + return load().agents?.[acpAgentId] ?? null; +} + +export function setPhoneMapping( + acpAgentId: string, + mapping: PhoneMapping +): void { + const store = load(); + store.agents ??= {}; + store.agents[acpAgentId] = mapping; + save(store); +} + +export function clearPhoneMapping(acpAgentId: string): void { + const store = load(); + if (!store.agents?.[acpAgentId]) return; + delete store.agents[acpAgentId]; + save(store); +} From de71773cefd65c28c3feeadce228984aa8e8f115 Mon Sep 17 00:00:00 2001 From: Celeste Ang Date: Fri, 15 May 2026 17:23:25 +0800 Subject: [PATCH 2/2] docs: flag AGENTPHONE_API_KEY as temporary, add phone roadmap Document the planned migration of `acp phone` from a direct CLI-to-AgentPhone integration to a backend-proxied flow, so future contributors know which files to expect changes in (only src/lib/api/agentphone.ts; src/commands/phone.ts stays thin) and what the backend needs to add. Also enumerate end-to-end-verified vs wired-but-unverified subcommands and the deliberate omissions (release, webhook secret management, SSE transcript streaming, tests). Co-Authored-By: Claude Opus 4.7 --- README.md | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a1297d5..0580858 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ All environment variables are optional. The CLI works out of the box after `acp | `IS_TESTNET` | — | Set to `true` to use testnet chains, API server, and Privy app | | `PARTNER_ID` | — | Partner ID for tokenization | | `ACP_CONFIG_DIR` | `~/.config/acp` | Directory holding the config file(s). The filename is picked per-env (below) | -| `AGENTPHONE_API_KEY` | — | API key for [AgentPhone](https://agentphone.ai). Required for any `acp phone …` command | +| `AGENTPHONE_API_KEY` | — | API key for [AgentPhone](https://agentphone.ai). Required for any `acp phone …` command. **Temporary** — see [Agent Phone Numbers § Implementation status](#implementation-status) for the planned migration to a backend-proxied flow. | | `AGENTPHONE_BASE_URL`| `https://api.agentphone.ai` | Override the AgentPhone API base URL (for self-hosted or staging environments) | Mainnet and testnet store state separately so identities don't mix when toggling `IS_TESTNET`: @@ -523,6 +523,45 @@ acp phone calls transcript > > Releasing numbers is intentionally not yet a CLI command — use the AgentPhone dashboard until a `release` subcommand with double-confirm lands. +#### Implementation status + +This integration is a first cut. The CLI talks to `api.agentphone.ai` directly from the user's machine — convenient for shipping fast, but it means every operator has to hold their own `AGENTPHONE_API_KEY` and there is no central place to enforce per-agent quotas or reconcile AgentPhone personas against ACP agent identities. + +**Planned migration → backend-proxied** + +The intended end state is for the ACP backend to own the AgentPhone integration: a single AgentPhone account on the server side, `AGENTPHONE_API_KEY` stored server-side, and the CLI calling ACP backend endpoints (mirroring how `acp email` and `acp card` already work). The CLI code is laid out to make that swap cheap: + +- `src/lib/api/agentphone.ts` — the only file that should need to change. Either repoint its base URL to an ACP backend route, or replace it with calls through the existing `agentApi` client. +- `src/commands/phone.ts` — kept deliberately thin (CLI flag parsing + output), so it should not need to change beyond removing the direct-mode env-var error path. +- `src/lib/phoneConfig.ts` — local mapping store; will be removed once the backend tracks the ACP-agent → AgentPhone-persona link as part of agent state. + +Backend changes required for the migration: + +1. New endpoints, ACP-authed, that proxy the AgentPhone REST surface (`/v1/agents`, `/v1/numbers`, `/v1/messages`, `/v1/calls`, `/v1/calls/{id}/transcript`, etc.). Per-agent scoping enforced server-side. +2. Persistence for the ACP-agent → AgentPhone-agent mapping, replacing `~/.config/acp/phone.json`. +3. Optional: webhook receiver endpoint that verifies AgentPhone's HMAC signature and fans out to per-agent consumers, so individual operators don't each have to stand up a webhook URL. +4. Quota / billing reconciliation (AgentPhone charges $3/month per number — the backend should expose this in usage views before allowing provisions). + +**What's exercised end-to-end against `api.agentphone.ai`** + +- `acp phone whoami` — pre-provision (no persona linked) and post-provision (linked persona + attached number) paths +- `acp phone provision --voice-mode hosted` — US number with area code, lazy-creates the AgentPhone persona, persists the local mapping + +**What's wired but not yet exercised end-to-end** + +- `acp phone numbers`, `acp phone attach` +- `acp phone sms send | inbox | thread` +- `acp phone call`, `acp phone calls list | transcript` +- `acp phone provision --voice-mode webhook` (needs a real `--webhook-url`) + +**Not implemented (deliberate)** + +- `acp phone release ` — destructive, deferred until a double-confirm UX is designed +- `acp phone webhook set ` + signing-secret rotation +- SSE live-transcript streaming (`GET /v1/calls/{id}/transcript/stream`) +- MMS upload helper (today `--media-urls` requires public HTTPS URLs the caller supplies) +- Automated tests — the repo has no test infrastructure today. Adding Vitest + recorded HTTP fixtures against AgentPhone belongs in its own PR. + ### Chain Info ```bash