feat: implement voice-call plugin

This commit is contained in:
Peter Steinberger
2026-01-11 23:23:14 +00:00
parent e576a82c43
commit 367baaca20
5 changed files with 449 additions and 121 deletions

View File

@@ -173,13 +173,15 @@ its present in your workspace/managed skills locations.
## Example plugin: Voice Call
This repo includes a voicecall placeholder plugin:
This repo includes a voicecall plugin (Twilio or log fallback):
- Source: `extensions/voice-call`
- Skill: `skills/voice-call`
- CLI: `clawdbot voicecall status`
- CLI: `clawdbot voicecall start|status`
- Tool: `voice_call`
- RPC: `voicecall.status`
- RPC: `voicecall.start`, `voicecall.status`
- Config (twilio): `provider: "twilio"` + `twilio.accountSid/authToken/from` (optional `statusCallbackUrl`, `twimlUrl`)
- Config (dev): `provider: "log"` (no network)
See `extensions/voice-call/README.md` for setup and usage.

View File

@@ -1,7 +1,6 @@
# Voice Call Plugin (Placeholder)
# Voice Call Plugin
This is a **stub** plugin used to validate the Clawdbot plugin API.
It does not place real calls yet.
Twilio-backed outbound voice calls (with a log-only fallback for dev).
## Install (local dev)
@@ -19,20 +18,41 @@ Option 2: add via config:
{
plugins: {
load: { paths: ["/absolute/path/to/extensions/voice-call"] },
entries: {
"voice-call": { enabled: true, config: { provider: "twilio" } }
}
entries: { "voice-call": { enabled: true } }
}
}
```
Restart the Gateway after changes.
## Config
Put under `plugins.entries.voice-call.config`:
```json5
{
provider: "twilio",
twilio: {
accountSid: "ACxxxxxxxx",
authToken: "your_token",
from: "+15551234567",
statusCallbackUrl: "https://example.com/twilio-status", // optional
twimlUrl: "https://example.com/twiml" // optional, else auto-generates <Say>
}
}
```
Dev fallback (no network):
```json5
{ provider: "log" }
```
## CLI
```bash
clawdbot voicecall status
clawdbot voicecall start --to "+15555550123" --message "Hello"
clawdbot voicecall start --to "+15555550123" --message "Hello from Clawdbot"
clawdbot voicecall status --sid CAxxxxxxxx
```
## Tool
@@ -40,13 +60,15 @@ clawdbot voicecall start --to "+15555550123" --message "Hello"
Tool name: `voice_call`
Parameters:
- `mode`: `"call" | "status"`
- `to`: target string
- `mode`: `"call" | "status"` (default: `call`)
- `to`: target string (required for call)
- `sid`: call SID (required for status)
- `message`: optional intro text
## Gateway RPC
- `voicecall.status`
- `voicecall.start` (to, message?)
- `voicecall.status` (sid)
## Skill
@@ -59,6 +81,5 @@ setting:
## Notes
- This plugin is a placeholder. Implement your real call flow in the tool and
RPC handlers.
- Uses Twilio REST API via fetch (no SDK). Provide valid SID/token/from.
- Use `voicecall.*` for RPC names and `voice_call` for tool naming consistency.

View File

@@ -1,121 +1,298 @@
import { Type } from "@sinclair/typebox";
const voiceCallConfigSchema = {
parse(value) {
if (value === undefined) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("voice-call config must be an object");
type VoiceCallConfig =
| {
provider: "twilio";
twilio: {
accountSid: string;
authToken: string;
from: string;
statusCallbackUrl?: string;
twimlUrl?: string;
};
}
return value;
| {
provider?: "log";
};
type VoiceCallStartParams = {
to: string;
message?: string;
};
type VoiceCallStatus = {
sid: string;
status: string;
provider: string;
to?: string;
from?: string;
};
const voiceCallConfigSchema = {
parse(value: unknown): VoiceCallConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { provider: "log" };
}
const cfg = value as Record<string, unknown>;
const provider =
cfg.provider === "twilio" || cfg.provider === "log"
? cfg.provider
: "log";
if (provider === "twilio") {
const twilio = cfg.twilio as Record<string, unknown> | undefined;
if (
!twilio ||
typeof twilio.accountSid !== "string" ||
typeof twilio.authToken !== "string" ||
typeof twilio.from !== "string"
) {
throw new Error(
"twilio provider requires twilio.accountSid, twilio.authToken, twilio.from",
);
}
return {
provider: "twilio",
twilio: {
accountSid: twilio.accountSid,
authToken: twilio.authToken,
from: twilio.from,
statusCallbackUrl:
typeof twilio.statusCallbackUrl === "string"
? twilio.statusCallbackUrl
: undefined,
twimlUrl:
typeof twilio.twimlUrl === "string" ? twilio.twimlUrl : undefined,
},
};
}
return { provider: "log" };
},
};
const escapeXml = (input: string): string =>
input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
async function startTwilioCall(
cfg: Exclude<VoiceCallConfig, { provider?: "log" }>["twilio"],
params: VoiceCallStartParams,
): Promise<VoiceCallStatus> {
const body = new URLSearchParams();
body.set("To", params.to);
body.set("From", cfg.from);
if (cfg.twimlUrl) {
body.set("Url", cfg.twimlUrl);
} else {
const say = escapeXml(params.message ?? "Hello from Clawdbot.");
body.set("Twiml", `<Response><Say>${say}</Say></Response>`);
}
if (cfg.statusCallbackUrl) {
body.set("StatusCallback", cfg.statusCallbackUrl);
}
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${cfg.accountSid}/Calls.json`,
{
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${cfg.accountSid}:${cfg.authToken}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`twilio call failed: ${res.status} ${text}`);
}
const payload = (await res.json()) as Record<string, unknown>;
return {
sid: String(payload.sid ?? ""),
status: String(payload.status ?? "unknown"),
provider: "twilio",
to: String(payload.to ?? params.to),
from: String(payload.from ?? cfg.from),
};
}
async function getTwilioStatus(
cfg: Exclude<VoiceCallConfig, { provider?: "log" }>["twilio"],
sid: string,
): Promise<VoiceCallStatus> {
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${cfg.accountSid}/Calls/${sid}.json`,
{
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${cfg.accountSid}:${cfg.authToken}`).toString("base64")}`,
},
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`twilio status failed: ${res.status} ${text}`);
}
const payload = (await res.json()) as Record<string, unknown>;
return {
sid,
status: String(payload.status ?? "unknown"),
provider: "twilio",
to: String(payload.to ?? ""),
from: String(payload.from ?? cfg.from),
};
}
async function startCall(
cfg: VoiceCallConfig,
params: VoiceCallStartParams,
): Promise<VoiceCallStatus> {
if (cfg.provider === "twilio") {
return startTwilioCall(cfg.twilio, params);
}
return {
sid: `log-${Date.now()}`,
status: "queued",
provider: "log",
to: params.to,
};
}
async function getStatus(
cfg: VoiceCallConfig,
sid: string,
): Promise<VoiceCallStatus> {
if (cfg.provider === "twilio") {
return getTwilioStatus(cfg.twilio, sid);
}
return { sid, status: "mock", provider: "log" };
}
const voiceCallPlugin = {
id: "voice-call",
name: "Voice Call",
description: "Voice-call plugin stub (placeholder)",
description: "Voice-call plugin with Twilio/log providers",
configSchema: voiceCallConfigSchema,
register(api) {
api.registerGatewayMethod("voicecall.status", ({ respond }) => {
respond(true, {
status: "idle",
provider: api.pluginConfig?.provider ?? "unset",
const cfg = voiceCallConfigSchema.parse(api.pluginConfig);
api.registerGatewayMethod("voicecall.start", async ({ params, respond }) => {
const to = typeof params?.to === "string" ? params.to.trim() : "";
const message =
typeof params?.message === "string" ? params.message.trim() : undefined;
if (!to) {
respond(false, { error: "to required" });
return;
}
try {
const result = await startCall(cfg, { to, message });
respond(true, result);
} catch (err) {
respond(false, { error: String(err) });
}
});
});
api.registerTool(
{
name: "voice_call",
label: "Voice Call",
description: "Start or inspect a voice call via the voice-call plugin",
parameters: Type.Object({
mode: Type.Optional(
Type.Union([Type.Literal("call"), Type.Literal("status")]),
),
to: Type.Optional(Type.String({ description: "Call target" })),
message: Type.Optional(
Type.String({ description: "Optional intro message" }),
),
}),
async execute(_toolCallId, params) {
if (params.mode === "status") {
return {
content: [
{
type: "text",
text: JSON.stringify({ status: "idle" }, null, 2),
},
],
details: { status: "idle" },
};
api.registerGatewayMethod(
"voicecall.status",
async ({ params, respond }) => {
const sid = typeof params?.sid === "string" ? params.sid.trim() : "";
if (!sid) {
respond(false, { error: "sid required" });
return;
}
try {
const result = await getStatus(cfg, sid);
respond(true, result);
} catch (err) {
respond(false, { error: String(err) });
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
status: "not_implemented",
to: params.to ?? null,
message: params.message ?? null,
},
null,
2,
),
},
],
details: {
status: "not_implemented",
to: params.to ?? null,
message: params.message ?? null,
},
};
},
},
{ name: "voice_call" },
);
);
api.registerCli(({ program }) => {
const voicecall = program
.command("voicecall")
.description("Voice call plugin commands");
voicecall
.command("status")
.description("Show voice-call status")
.action(() => {
console.log(JSON.stringify({ status: "idle" }, null, 2));
});
voicecall
.command("start")
.description("Start a voice call (placeholder)")
.option("--to <target>", "Target to call")
.option("--message <text>", "Optional intro message")
.action((opts) => {
console.log(
JSON.stringify(
{
status: "not_implemented",
to: opts.to ?? null,
message: opts.message ?? null,
},
null,
2,
api.registerTool(
{
name: "voice_call",
label: "Voice Call",
description: "Start or inspect a voice call via the voice-call plugin",
parameters: Type.Object({
mode: Type.Optional(
Type.Union([Type.Literal("call"), Type.Literal("status")]),
),
);
});
}, { commands: ["voicecall"] });
to: Type.Optional(Type.String({ description: "Call target" })),
sid: Type.Optional(Type.String({ description: "Call SID" })),
message: Type.Optional(
Type.String({ description: "Optional intro message" }),
),
}),
async execute(_toolCallId, params) {
const mode = params.mode ?? "call";
if (mode === "status") {
if (typeof params.sid !== "string") {
throw new Error("sid required for status");
}
const status = await getStatus(cfg, params.sid.trim());
return {
content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
details: status,
};
}
const to =
typeof params.to === "string" && params.to.trim()
? params.to.trim()
: null;
if (!to) throw new Error("to required for call");
const result = await startCall(cfg, { to, message: params.message });
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
details: result,
};
},
},
{ name: "voice_call" },
);
api.registerService({
id: "voicecall",
start: () => {
api.logger.info("voice-call service ready (placeholder)");
},
stop: () => {
api.logger.info("voice-call service stopped (placeholder)");
},
});
api.registerCli(({ program }) => {
const voicecall = program
.command("voicecall")
.description("Voice call plugin commands");
voicecall
.command("start")
.description("Start a voice call")
.requiredOption("--to <target>", "Target to call")
.option("--message <text>", "Optional intro message")
.action(async (opts) => {
const result = await startCall(cfg, {
to: opts.to,
message: opts.message,
});
console.log(JSON.stringify(result, null, 2));
});
voicecall
.command("status")
.description("Show voice-call status")
.requiredOption("--sid <sid>", "Call SID")
.action(async (opts) => {
const result = await getStatus(cfg, opts.sid);
console.log(JSON.stringify(result, null, 2));
});
}, { commands: ["voicecall"] });
api.registerService({
id: "voicecall",
start: () => {
api.logger.info(`voice-call provider: ${cfg.provider}`);
},
stop: () => {
api.logger.info("voice-call service stopped");
},
});
},
};

View File

@@ -6,13 +6,13 @@ metadata: {"clawdbot":{"emoji":"📞","skillKey":"voice-call","requires":{"confi
# Voice Call
Use the voice-call plugin to start or inspect calls.
Use the voice-call plugin to start or inspect calls (Twilio or log fallback).
## CLI
```bash
clawdbot voicecall status
clawdbot voicecall start --to "+15555550123" --message "Hello"
clawdbot voicecall start --to "+15555550123" --message "Hello from Clawdbot"
clawdbot voicecall status --sid CAxxxxxxxx
```
## Tool
@@ -20,10 +20,13 @@ clawdbot voicecall start --to "+15555550123" --message "Hello"
Use `voice_call` for agent-initiated calls.
Parameters:
- `to` (string): phone number or provider target
- `mode` ("call" | "status", optional, default call)
- `to` (string): phone number / target (required for call)
- `sid` (string): call SID (required for status)
- `message` (string, optional): optional intro or instruction
- `mode` ("call" | "status", optional)
Notes:
- Requires the voice-call plugin to be enabled.
- Plugin config lives under `plugins.entries.voice-call.config`.
- Twilio config: `provider: "twilio"` + `twilio.accountSid/authToken/from` (statusCallbackUrl/twimlUrl optional).
- Dev fallback: `provider: "log"` (no network).

View File

@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import plugin from "../../extensions/voice-call/index.js";
const noopLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
type Registered = {
methods: Map<
string,
(ctx: Record<string, unknown>) => Promise<unknown> | unknown
>;
tools: unknown[];
};
function setup(config: Record<string, unknown>): Registered {
const methods = new Map<
string,
(ctx: Record<string, unknown>) => Promise<unknown> | unknown
>();
const tools: unknown[] = [];
plugin.register({
id: "voice-call",
name: "Voice Call",
description: "test",
version: "0",
source: "test",
config: {},
pluginConfig: config,
logger: noopLogger,
registerGatewayMethod: (method, handler) => methods.set(method, handler),
registerTool: (tool) => tools.push(tool),
registerCli: () => {},
registerService: () => {},
resolvePath: (p: string) => p,
});
return { methods, tools };
}
describe("voice-call plugin", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("starts a log provider call and returns sid", async () => {
const { methods } = setup({ provider: "log" });
const handler = methods.get("voicecall.start");
const respond = vi.fn();
await handler({ params: { to: "+123", message: "Hi" }, respond });
expect(respond).toHaveBeenCalledTimes(1);
const [ok, payload] = respond.mock.calls[0];
expect(ok).toBe(true);
expect(payload.sid).toMatch(/^log-/);
expect(payload.status).toBe("queued");
});
it("fetches status via log provider", async () => {
const { methods } = setup({ provider: "log" });
const handler = methods.get("voicecall.status");
const respond = vi.fn();
await handler({ params: { sid: "log-1" }, respond });
const [ok, payload] = respond.mock.calls[0];
expect(ok).toBe(true);
expect(payload.status).toBe("mock");
});
it("calls Twilio start endpoint", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
sid: "CA123",
status: "queued",
to: "+1555",
from: "+1444",
}),
});
// @ts-expect-error partial global
global.fetch = fetch;
const { methods } = setup({
provider: "twilio",
twilio: {
accountSid: "AC123",
authToken: "tok",
from: "+1444",
statusCallbackUrl: "https://callback.test/status",
},
});
const handler = methods.get("voicecall.start");
const respond = vi.fn();
await handler({ params: { to: "+1555", message: "Hello" }, respond });
expect(fetch).toHaveBeenCalledTimes(1);
const [ok, payload] = respond.mock.calls[0];
expect(ok).toBe(true);
expect(payload.sid).toBe("CA123");
expect(payload.provider).toBe("twilio");
});
it("fetches Twilio status", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: "in-progress", to: "+1" }),
});
// @ts-expect-error partial global
global.fetch = fetch;
const { methods } = setup({
provider: "twilio",
twilio: { accountSid: "AC123", authToken: "tok", from: "+1444" },
});
const handler = methods.get("voicecall.status");
const respond = vi.fn();
await handler({ params: { sid: "CA123" }, respond });
expect(fetch).toHaveBeenCalled();
const [ok, payload] = respond.mock.calls[0];
expect(ok).toBe(true);
expect(payload.status).toBe("in-progress");
});
});