Skip to content

Commit 991cf28

Browse files
authored
Merge pull request #20 from team-telnyx/bot-config
feat(TALK-16): adding bot configuration tool
2 parents c86223f + 55ffa76 commit 991cf28

File tree

13 files changed

+703
-3
lines changed

13 files changed

+703
-3
lines changed

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.2.0] - 2026-03-25
6+
7+
### Added
8+
- **BotConfigTool** (`clawtalk_bot_config`): New tool for managing bot settings
9+
- `get` action: Read current bot config (name, role, greeting, voice, language, instructions)
10+
- `update` action: Update any combination of bot settings
11+
- `list_voices` action: Browse 2,200+ TTS voices with filters (provider, language, gender, accent, search)
12+
- Voice cache with 5 minute TTL per provider
13+
- Results capped at 20 with total count to prevent context bloat
14+
- **VoicesNamespace** in ClawTalk SDK: `client.voices.list(provider?)` method
15+
- 22 new tests for BotConfigTool
16+
17+
### Changed
18+
- **Mission assistant voice inheritance**: `clawtalk_mission_setup_agent` no longer defaults to `Rime.ArcanaV3.astra`
19+
- If `voice` param omitted, server now uses user's `voice_preference` (with fallback to system default)
20+
- Explicit `voice` param still works for override
21+
- Updated MissionTool schema description to reflect voice inheritance behavior
22+
523
## [0.1.4] - 2026-03-16
624

725
### Removed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "clawtalk",
3-
"version": "0.1.6",
3+
"version": "0.2.0",
44
"description": "Voice calls, SMS, missions, and approvals via ClawTalk — OpenClaw plugin",
55
"main": "./build/index.js",
66
"types": "./build/index.d.ts",

src/lib/clawtalk-sdk/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { MissionsNamespace } from './namespaces/missions.js';
2020
import { NumbersNamespace } from './namespaces/numbers.js';
2121
import { SmsNamespace } from './namespaces/sms.js';
2222
import { UserNamespace } from './namespaces/user.js';
23+
import { VoicesNamespace } from './namespaces/voices.js';
2324

2425
export interface ClawTalkClientConfig {
2526
readonly apiKey: string;
@@ -42,6 +43,7 @@ export class ClawTalkClient {
4243
readonly numbers: NumbersNamespace;
4344
readonly insights: InsightsNamespace;
4445
readonly doctor: DoctorNamespace;
46+
readonly voices: VoicesNamespace;
4547

4648
private readonly baseUrl: string;
4749
private readonly headers: Record<string, string>;
@@ -69,6 +71,7 @@ export class ClawTalkClient {
6971
this.numbers = new NumbersNamespace(request);
7072
this.insights = new InsightsNamespace(request);
7173
this.doctor = new DoctorNamespace(request);
74+
this.voices = new VoicesNamespace(request);
7275
}
7376

7477
private async request<T>(method: string, endpoint: string, body?: unknown): Promise<T> {

src/lib/clawtalk-sdk/endpoints.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export interface Endpoint {
3737
export const ENDPOINTS = {
3838
// ── User (/v1 + user.js) ────────────────────────────────
3939
getMe: { method: 'GET', path: '/v1/me', sdkMethod: 'getMe', write: false },
40+
updateMe: { method: 'PATCH', path: '/v1/me', sdkMethod: 'updateMe', write: true },
41+
42+
// ── Voices (/v1 + user.js) ───────────────────────────────
43+
listVoices: { method: 'GET', path: '/v1/voices', sdkMethod: 'listVoices', write: false },
4044

4145
// ── Calls (/v1/calls + calls.js) ────────────────────────
4246
initiateCall: { method: 'POST', path: '/v1/calls', sdkMethod: 'initiateCall', write: true },

src/lib/clawtalk-sdk/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,6 @@ export type {
6666
SmsResponse,
6767
UpdateStepParams,
6868
UserMeResponse,
69+
Voice,
70+
VoicesResponse,
6971
} from './types.js';

src/lib/clawtalk-sdk/namespaces/user.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,8 @@ export class UserNamespace {
88
async me(): Promise<UserMeResponse> {
99
return this.request<UserMeResponse>('GET', ENDPOINTS.getMe.path);
1010
}
11+
12+
async updateMe(fields: Record<string, unknown>): Promise<void> {
13+
await this.request('PATCH', ENDPOINTS.updateMe.path, fields);
14+
}
1115
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ENDPOINTS } from '../endpoints.js';
2+
import type { VoicesResponse } from '../types.js';
3+
import type { RequestFn } from './calls.js';
4+
5+
export class VoicesNamespace {
6+
constructor(private readonly request: RequestFn) {}
7+
8+
async list(provider?: string): Promise<VoicesResponse> {
9+
const query = provider ? `?provider=${encodeURIComponent(provider)}` : '';
10+
return this.request<VoicesResponse>('GET', `${ENDPOINTS.listVoices.path}${query}`);
11+
}
12+
}

src/lib/clawtalk-sdk/types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export interface UserMeResponse {
3636
readonly subscription_status: string;
3737
readonly paranoid_mode: boolean;
3838
readonly voice_preference: string | null;
39+
readonly agent_name: string | null;
40+
readonly display_name: string | null;
41+
readonly bot_role: string | null;
42+
readonly custom_instructions: string | null;
43+
readonly greeting: string | null;
3944
readonly system_number: string | null;
4045
readonly dedicated_number: string | null;
4146
readonly totp_enabled: boolean;
@@ -62,6 +67,27 @@ export interface UserMeResponse {
6267
};
6368
}
6469

70+
// ── Voices ────────────────────────────────────────────────────
71+
72+
export interface Voice {
73+
readonly id: string;
74+
readonly name: string;
75+
readonly provider: string;
76+
readonly model: string | null;
77+
readonly language: string;
78+
readonly gender: string | null;
79+
readonly label: string | null;
80+
readonly accent: string | null;
81+
readonly age: string | null;
82+
}
83+
84+
export interface VoicesResponse {
85+
readonly default_voice: string;
86+
readonly voices: Voice[];
87+
readonly providers: string[];
88+
readonly total: number;
89+
}
90+
6591
// ── Calls ─────────────────────────────────────────────────────
6692

6793
export interface InitiateCallParams {

src/services/MissionService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export class MissionService {
221221
name: params.name,
222222
instructions: params.instructions,
223223
greeting: params.greeting ?? '',
224-
voice: params.voice ?? 'Rime.ArcanaV3.astra',
224+
voice: params.voice,
225225
model: params.model ?? 'openai/gpt-4o',
226226
tools: params.tools as Array<{ type: string; [key: string]: unknown }>,
227227
enabled_features: params.features ?? ['telephony', 'messaging'],

src/tools/BotConfigTool.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* BotConfigTool — clawtalk_bot_config: Read, update bot configuration, or browse voices.
3+
*/
4+
5+
import { Type } from '@sinclair/typebox';
6+
import type { ClawTalkClient } from '../lib/clawtalk-sdk/index.js';
7+
import type { Voice } from '../lib/clawtalk-sdk/types.js';
8+
import type { Logger } from '../types/plugin.js';
9+
import { ToolError } from '../utils/errors.js';
10+
11+
// ── Schema ──────────────────────────────────────────────────
12+
13+
export const BotConfigToolSchema = Type.Object({
14+
action: Type.Union([Type.Literal('get'), Type.Literal('update'), Type.Literal('list_voices')]),
15+
agent_name: Type.Optional(Type.String({ description: 'Bot name (e.g. Daisy)' })),
16+
bot_role: Type.Optional(Type.String({ description: 'Bot role (e.g. live phone voice for Smokies Motels)' })),
17+
custom_instructions: Type.Optional(
18+
Type.String({ description: 'Custom behaviour instructions, business rules, pricing, etc.' }),
19+
),
20+
greeting: Type.Optional(Type.String({ description: 'Greeting spoken when a call connects' })),
21+
voice_preference: Type.Optional(Type.String({ description: 'Voice ID (e.g. Rime.ArcanaV3.astra)' })),
22+
// list_voices filters
23+
provider: Type.Optional(
24+
Type.String({
25+
description:
26+
'Voice provider. Required for list_voices (default: "rime"). Options: rime, minimax, telnyx, inworld, resemble, aws, azure',
27+
}),
28+
),
29+
language: Type.Optional(Type.String({ description: 'Filter by language code (e.g. "en", "es", "fr-FR")' })),
30+
gender: Type.Optional(Type.String({ description: 'Filter by gender ("Male" or "Female")' })),
31+
accent: Type.Optional(Type.String({ description: 'Filter by accent (e.g. "British", "Southern American")' })),
32+
search: Type.Optional(Type.String({ description: 'Search voice name or label' })),
33+
});
34+
35+
// ── Voice Cache ─────────────────────────────────────────────
36+
37+
interface CacheEntry {
38+
voices: Voice[];
39+
providers: string[];
40+
defaultVoice: string;
41+
fetchedAt: number;
42+
}
43+
44+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
45+
const voiceCache = new Map<string, CacheEntry>();
46+
47+
// ── Helpers ──────────────────────────────────────────────────
48+
49+
function formatResult(payload: unknown) {
50+
return {
51+
content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
52+
details: payload,
53+
};
54+
}
55+
56+
function truncate(str: string | null, max: number): string | null {
57+
if (!str) return null;
58+
return str.length > max ? `${str.slice(0, max - 1)}_` : str;
59+
}
60+
61+
// ── Tool ─────────────────────────────────────────────────────
62+
63+
export class BotConfigTool {
64+
readonly name = 'clawtalk_bot_config';
65+
readonly label = 'ClawTalk Bot Config';
66+
readonly description =
67+
'Read or update the bot configuration (name, role, custom instructions, greeting, voice). Use action "get" to read current config, "update" to change fields, "list_voices" to browse available voices by provider.';
68+
readonly parameters = BotConfigToolSchema;
69+
70+
private readonly client: ClawTalkClient;
71+
private readonly logger: Logger;
72+
73+
constructor(params: { client: ClawTalkClient; logger: Logger }) {
74+
this.client = params.client;
75+
this.logger = params.logger;
76+
}
77+
78+
async execute(_toolCallId: string, raw: Record<string, unknown>) {
79+
const { action } = raw as { action: string };
80+
81+
if (action === 'get') {
82+
return this.handleGet();
83+
}
84+
85+
if (action === 'update') {
86+
return this.handleUpdate(raw);
87+
}
88+
89+
if (action === 'list_voices') {
90+
return this.handleListVoices(raw);
91+
}
92+
93+
throw new ToolError('clawtalk_bot_config', `Unknown action: ${action}`);
94+
}
95+
96+
private async handleGet() {
97+
this.logger.info('Getting bot config');
98+
try {
99+
const me = await this.client.user.me();
100+
const config = {
101+
agent_name: me.agent_name ?? null,
102+
display_name: me.display_name ?? null,
103+
bot_role: me.bot_role ?? 'personal AI assistant',
104+
custom_instructions: me.custom_instructions ?? null,
105+
greeting: me.greeting ?? null,
106+
voice_preference: me.voice_preference ?? null,
107+
};
108+
return formatResult(config);
109+
} catch (err) {
110+
throw ToolError.fromError('clawtalk_bot_config', err);
111+
}
112+
}
113+
114+
private async handleUpdate(raw: Record<string, unknown>) {
115+
this.logger.info('Updating bot config');
116+
const fields: Record<string, unknown> = {};
117+
for (const key of ['agent_name', 'bot_role', 'custom_instructions', 'greeting', 'voice_preference']) {
118+
if (raw[key] !== undefined) fields[key] = raw[key];
119+
}
120+
if (Object.keys(fields).length === 0) {
121+
throw new ToolError('clawtalk_bot_config', 'No fields provided for update');
122+
}
123+
try {
124+
await this.client.user.updateMe(fields);
125+
const me = await this.client.user.me();
126+
const config = {
127+
agent_name: me.agent_name ?? null,
128+
display_name: me.display_name ?? null,
129+
bot_role: me.bot_role ?? 'personal AI assistant',
130+
custom_instructions: me.custom_instructions ?? null,
131+
greeting: me.greeting ?? null,
132+
voice_preference: me.voice_preference ?? null,
133+
message: 'Bot config updated. Changes take effect on the next call.',
134+
};
135+
return formatResult(config);
136+
} catch (err) {
137+
throw ToolError.fromError('clawtalk_bot_config', err);
138+
}
139+
}
140+
141+
private async handleListVoices(raw: Record<string, unknown>) {
142+
const provider = (raw.provider as string) || 'rime';
143+
const language = raw.language as string | undefined;
144+
const gender = raw.gender as string | undefined;
145+
const accent = raw.accent as string | undefined;
146+
const search = raw.search as string | undefined;
147+
148+
this.logger.info(`Listing voices for provider: ${provider}`);
149+
150+
try {
151+
// Check cache
152+
const cached = voiceCache.get(provider);
153+
let voices: Voice[];
154+
let defaultVoice: string;
155+
let allProviders: string[];
156+
157+
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
158+
voices = cached.voices;
159+
defaultVoice = cached.defaultVoice;
160+
allProviders = cached.providers;
161+
} else {
162+
const result = await this.client.voices.list(provider);
163+
voices = result.voices;
164+
defaultVoice = result.default_voice;
165+
allProviders = result.providers;
166+
voiceCache.set(provider, {
167+
voices,
168+
providers: allProviders,
169+
defaultVoice,
170+
fetchedAt: Date.now(),
171+
});
172+
}
173+
174+
// Apply client-side filters
175+
let filtered = voices;
176+
177+
if (language) {
178+
const lang = language.toLowerCase();
179+
filtered = filtered.filter((v) => v.language.toLowerCase().startsWith(lang));
180+
}
181+
182+
if (gender) {
183+
const g = gender.toLowerCase();
184+
filtered = filtered.filter((v) => v.gender?.toLowerCase() === g);
185+
}
186+
187+
if (accent) {
188+
const a = accent.toLowerCase();
189+
filtered = filtered.filter((v) => v.accent?.toLowerCase().includes(a));
190+
}
191+
192+
if (search) {
193+
const s = search.toLowerCase();
194+
filtered = filtered.filter(
195+
(v) =>
196+
v.name.toLowerCase().includes(s) ||
197+
v.id.toLowerCase().includes(s) ||
198+
(v.label?.toLowerCase().includes(s) ?? false),
199+
);
200+
}
201+
202+
const totalMatching = filtered.length;
203+
const capped = filtered.slice(0, 20);
204+
205+
return formatResult({
206+
default_voice: defaultVoice,
207+
provider,
208+
providers: allProviders,
209+
total_matching: totalMatching,
210+
showing: capped.length,
211+
voices: capped.map((v) => ({
212+
id: v.id,
213+
name: v.name,
214+
provider: v.provider,
215+
language: v.language,
216+
gender: v.gender,
217+
label: truncate(v.label, 80),
218+
})),
219+
});
220+
} catch (err) {
221+
throw ToolError.fromError('clawtalk_bot_config', err);
222+
}
223+
}
224+
}

0 commit comments

Comments
 (0)