@trillet-ai/mcp 0.7.3

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.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Parent-side text-call session manager: forks the worker (see worker.ts for
3
+ * why it must be a child process), speaks the IPC protocol, and turns the
4
+ * stream of agent events into blocking "send a turn, get the reply" calls.
5
+ */
6
+ import { fork } from "node:child_process";
7
+ import { randomUUID } from "node:crypto";
8
+ const sessions = new Map();
9
+ export function getSession(id) {
10
+ return sessions.get(id);
11
+ }
12
+ export function activeSessionCount(workspaceId) {
13
+ let n = 0;
14
+ for (const s of sessions.values()) {
15
+ if (s.ended)
16
+ continue;
17
+ if (workspaceId && s.workspaceId !== workspaceId)
18
+ continue;
19
+ n += 1;
20
+ }
21
+ return n;
22
+ }
23
+ export function listSessions() {
24
+ return [...sessions.values()];
25
+ }
26
+ /** An unknown-session error that explains the two ways sessions disappear. */
27
+ export function unknownSession(id) {
28
+ return new Error(`Unknown text-call session '${id}'. Sessions live only inside one MCP server process — ` +
29
+ `start a new call with trillet_start_text_call. (Active: ${[...sessions.values()]
30
+ .filter((s) => !s.ended)
31
+ .map((s) => s.id)
32
+ .join(", ") || "none"})`);
33
+ }
34
+ function notify(s) {
35
+ const waiters = s.waiters.splice(0);
36
+ for (const w of waiters)
37
+ w();
38
+ }
39
+ function markEnded(s, reason) {
40
+ if (!s.ended) {
41
+ s.ended = true;
42
+ s.endReason = reason;
43
+ }
44
+ notify(s);
45
+ }
46
+ /** Fork the worker and wire IPC. `selfPath` is this server's own entry file. */
47
+ export function createSession(opts) {
48
+ const child = fork(opts.selfPath, [], {
49
+ env: { ...process.env, TRILLET_MCP_TEXTCALL_WORKER: "1" },
50
+ // stdout MUST be discarded: rtc-node's pino logs there, and in the parent
51
+ // stdout is the MCP transport. stderr is forwarded for debuggability.
52
+ stdio: ["ignore", "ignore", "pipe", "ipc"],
53
+ execArgv: [],
54
+ });
55
+ const s = {
56
+ id: `tc_${randomUUID().slice(0, 8)}`,
57
+ child,
58
+ roomName: opts.roomName,
59
+ callId: opts.callId,
60
+ callHistoryId: opts.callHistoryId,
61
+ agentId: opts.agentId,
62
+ agentName: opts.agentName,
63
+ workspaceId: opts.workspaceId,
64
+ transcript: [],
65
+ queue: [],
66
+ ended: false,
67
+ endReason: null,
68
+ lastActivityAt: Date.now(),
69
+ waiters: [],
70
+ notifyWaiters: () => notify(s),
71
+ nextIpcId: 1,
72
+ pending: new Map(),
73
+ };
74
+ child.stderr?.on("data", (d) => process.stderr.write(`[textcall ${s.id}] ${d}`));
75
+ child.on("message", (msg) => {
76
+ if (msg && typeof msg.id === "number" && s.pending.has(msg.id)) {
77
+ const p = s.pending.get(msg.id);
78
+ s.pending.delete(msg.id);
79
+ if (msg.ok)
80
+ p.resolve(msg);
81
+ else
82
+ p.reject(new Error(msg.error || "worker error"));
83
+ return;
84
+ }
85
+ if (msg?.event === "agent_turn") {
86
+ const identity = String(msg.identity ?? "");
87
+ const text = String(msg.text ?? "").trim();
88
+ // Transcription streams also echo caller turns under the caller's own
89
+ // identity; only agent-identity streams are agent speech.
90
+ const isAgent = identity.startsWith("agent-") || identity.startsWith("web-agent");
91
+ if (!isAgent || !text)
92
+ return;
93
+ // The agent occasionally re-emits an identical segment; drop exact
94
+ // repeats of the latest turn (same dedup the dashboard panel applies).
95
+ const last = s.transcript[s.transcript.length - 1];
96
+ if (last?.role === "agent" && last.text === text)
97
+ return;
98
+ s.transcript.push({ role: "agent", text, at: new Date().toISOString() });
99
+ s.queue.push(text);
100
+ s.lastActivityAt = Date.now();
101
+ notify(s);
102
+ }
103
+ else if (msg?.event === "disconnected") {
104
+ markEnded(s, "call_ended");
105
+ }
106
+ });
107
+ child.on("exit", () => markEnded(s, s.ended ? s.endReason : "worker_exited"));
108
+ child.on("error", () => markEnded(s, "worker_error"));
109
+ sessions.set(s.id, s);
110
+ return s;
111
+ }
112
+ /** Send an IPC command and await the worker's reply. */
113
+ export function rpc(s, cmd, params, timeoutMs) {
114
+ return new Promise((resolve, reject) => {
115
+ if (!s.child.connected) {
116
+ reject(new Error("text-call worker is not running (call already ended?)"));
117
+ return;
118
+ }
119
+ const id = s.nextIpcId++;
120
+ const timer = setTimeout(() => {
121
+ s.pending.delete(id);
122
+ reject(new Error(`worker '${cmd}' timed out after ${timeoutMs / 1000}s`));
123
+ }, timeoutMs);
124
+ s.pending.set(id, {
125
+ resolve: (m) => {
126
+ clearTimeout(timer);
127
+ resolve(m);
128
+ },
129
+ reject: (e) => {
130
+ clearTimeout(timer);
131
+ reject(e);
132
+ },
133
+ });
134
+ s.child.send({ id, cmd, ...params });
135
+ });
136
+ }
137
+ /** Resolve when the session sees a new event, or after ms. True = event. */
138
+ function waitEvent(s, ms) {
139
+ return new Promise((resolve) => {
140
+ const timer = setTimeout(() => resolve(false), ms);
141
+ s.waiters.push(() => {
142
+ clearTimeout(timer);
143
+ resolve(true);
144
+ });
145
+ });
146
+ }
147
+ /**
148
+ * Collect the agent's next turn(s): wait for the first completed turn (or the
149
+ * call ending), then keep draining until `quietMs` passes with nothing new —
150
+ * node flows can chain several messages (say node + conversation node) in one
151
+ * logical turn, and a farewell is often followed ~1s later by the disconnect.
152
+ */
153
+ export async function collectAgentTurns(s, { timeoutMs, quietMs = 1500 }) {
154
+ const deadline = Date.now() + timeoutMs;
155
+ const turns = [];
156
+ while (s.queue.length === 0 && !s.ended && Date.now() < deadline) {
157
+ await waitEvent(s, Math.min(500, Math.max(50, deadline - Date.now())));
158
+ }
159
+ const timedOut = s.queue.length === 0 && !s.ended;
160
+ for (;;) {
161
+ while (s.queue.length)
162
+ turns.push(s.queue.shift());
163
+ if (s.ended)
164
+ break;
165
+ const sawEvent = await waitEvent(s, quietMs);
166
+ if (!sawEvent && s.queue.length === 0)
167
+ break;
168
+ }
169
+ return { turns, timedOut };
170
+ }
171
+ /** Tear a session down: disconnect the room, kill the worker, keep the record. */
172
+ export async function endSession(s, reason) {
173
+ try {
174
+ if (s.child.connected)
175
+ await rpc(s, "end", {}, 5_000);
176
+ }
177
+ catch {
178
+ /* worker already gone */
179
+ }
180
+ try {
181
+ s.child.kill();
182
+ }
183
+ catch {
184
+ /* already dead */
185
+ }
186
+ markEnded(s, reason);
187
+ }
188
+ /** Kill workers whose session has been idle too long (agent-side timers will
189
+ * have ended the call server-side well before this fires). */
190
+ const REAPER_IDLE_MS = 15 * 60 * 1000;
191
+ const reaper = setInterval(() => {
192
+ for (const s of sessions.values()) {
193
+ if (!s.ended && Date.now() - s.lastActivityAt > REAPER_IDLE_MS) {
194
+ void endSession(s, "idle_reaped");
195
+ }
196
+ }
197
+ }, 60_000);
198
+ reaper.unref();
199
+ // Never leave orphan workers holding rooms open.
200
+ process.on("exit", () => {
201
+ for (const s of sessions.values()) {
202
+ try {
203
+ s.child.kill();
204
+ }
205
+ catch {
206
+ /* ignore */
207
+ }
208
+ }
209
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Text-call worker: the actual LiveKit room participant.
3
+ *
4
+ * Runs as a forked child of the MCP server (same bundled file, selected by
5
+ * TRILLET_MCP_TEXTCALL_WORKER=1) and talks to the parent over the IPC channel.
6
+ * It MUST be a separate process: @livekit/rtc-node's pino logger writes JSON
7
+ * lines to STDOUT (verified — not configurable), which would corrupt the
8
+ * parent's stdio MCP transport. The fork also isolates native FFI crashes.
9
+ *
10
+ * Protocol (verified live against production 2026-08-27/28, identical to the
11
+ * dashboard Test panel):
12
+ * - caller turns are sent with sendText(text, { topic: "lk.chat" })
13
+ * - agent turns arrive as text streams on topic "lk.transcription"
14
+ * - the "lk.transcription_final" attribute is NOT trustworthy (it is "false"
15
+ * on completed turns); a turn is simply one stream read to close
16
+ * - agent identities start with "agent-" (or "web-agent")
17
+ *
18
+ * IPC messages
19
+ * parent -> worker: {id, cmd:"connect", url, token} | {id, cmd:"say", text} | {id, cmd:"end"}
20
+ * worker -> parent: {id, ok, ...} replies, plus unsolicited events:
21
+ * {event:"agent_turn", identity, text}
22
+ * {event:"participant", identity}
23
+ * {event:"disconnected"}
24
+ */
25
+ import { createRequire } from "node:module";
26
+ import { join } from "node:path";
27
+ import { depsDir } from "./deps.js";
28
+ function send(msg) {
29
+ try {
30
+ process.send?.(msg);
31
+ }
32
+ catch {
33
+ /* parent gone; the disconnect handler exits us */
34
+ }
35
+ }
36
+ /** The handler's 2nd arg is a participant info OBJECT in rtc-node (a plain identity string in the browser SDK). */
37
+ function identityOf(participant) {
38
+ if (typeof participant === "string")
39
+ return participant;
40
+ return String(participant?.identity ?? "");
41
+ }
42
+ export function runTextCallWorker() {
43
+ let room = null;
44
+ process.on("message", async (msg) => {
45
+ try {
46
+ if (msg.cmd === "connect") {
47
+ const req = createRequire(join(depsDir(), "package.json"));
48
+ const lk = req("@livekit/rtc-node");
49
+ room = new lk.Room();
50
+ const relay = (topic) => async (reader, participant) => {
51
+ const identity = identityOf(participant);
52
+ let text = "";
53
+ try {
54
+ for await (const chunk of reader)
55
+ text += chunk;
56
+ }
57
+ catch {
58
+ /* stream aborted mid-read (room closing); forward what we have */
59
+ }
60
+ send({ event: "agent_turn", topic, identity, text });
61
+ };
62
+ // Handlers must be registered before connect or early streams are lost.
63
+ room.registerTextStreamHandler("lk.transcription", relay("lk.transcription"));
64
+ room.registerTextStreamHandler("lk.chat", relay("lk.chat"));
65
+ room.on(lk.RoomEvent.ParticipantConnected, (p) => send({ event: "participant", identity: identityOf(p) }));
66
+ room.on(lk.RoomEvent.Disconnected, () => send({ event: "disconnected" }));
67
+ // autoSubscribe:false matches the Test panel in text mode — text
68
+ // streams are data packets, not tracks, so nothing is lost.
69
+ await room.connect(msg.url, msg.token, { autoSubscribe: false, dynacast: false });
70
+ send({
71
+ id: msg.id,
72
+ ok: true,
73
+ roomName: room.name,
74
+ localIdentity: identityOf(room.localParticipant),
75
+ });
76
+ }
77
+ else if (msg.cmd === "say") {
78
+ if (!room)
79
+ throw new Error("not connected");
80
+ await room.localParticipant.sendText(msg.text ?? "", { topic: "lk.chat" });
81
+ send({ id: msg.id, ok: true });
82
+ }
83
+ else if (msg.cmd === "end") {
84
+ try {
85
+ await room?.disconnect();
86
+ }
87
+ catch {
88
+ /* already closed */
89
+ }
90
+ send({ id: msg.id, ok: true });
91
+ // FFI threads keep the loop alive; exit explicitly once the ack flushed.
92
+ setTimeout(() => process.exit(0), 200);
93
+ }
94
+ }
95
+ catch (e) {
96
+ send({ id: msg.id, ok: false, error: e?.message ?? String(e) });
97
+ }
98
+ });
99
+ // Parent died or ended the session: never outlive it.
100
+ process.on("disconnect", () => process.exit(0));
101
+ }
@@ -0,0 +1,86 @@
1
+ import { z } from "zod";
2
+ import { ok, guard, workspaceParam } from "../helpers.js";
3
+ export function registerAccountTools(server, client) {
4
+ server.registerTool("trillet_get_usage", {
5
+ title: "Get wallet balance and usage",
6
+ description: "Fetch the workspace's wallet/billing dashboard summary: balance, subscription, and usage. include_minutes_breakdown adds the per-month minutes usage breakdown for the last 3 months. Check this first when calls or workflow steps mysteriously don't run - outbound calls and SMS steps require a positive wallet balance.",
7
+ inputSchema: {
8
+ include_minutes_breakdown: z
9
+ .boolean()
10
+ .default(false)
11
+ .describe("Also fetch the 3-month minutes usage breakdown"),
12
+ ...workspaceParam,
13
+ },
14
+ annotations: { readOnlyHint: true, openWorldHint: true },
15
+ }, guard(async ({ include_minutes_breakdown, workspace_id }) => {
16
+ const summary = await client.request("GET", "/v1/api/wallet/dashboard-summary", {
17
+ workspaceId: workspace_id,
18
+ });
19
+ const out = { summary };
20
+ if (include_minutes_breakdown) {
21
+ out.minutesBreakdown = await client
22
+ .request("GET", "/v1/api/billing/minutes-usage-breakdown/self", {
23
+ workspaceId: workspace_id,
24
+ })
25
+ .catch((e) => ({ error: String(e?.message ?? e) }));
26
+ }
27
+ return ok(out);
28
+ }));
29
+ server.registerTool("trillet_list_transactions", {
30
+ title: "List billing transactions",
31
+ description: "The workspace's wallet transaction ledger - the source of truth for what was actually charged. Types include call_charge, sms_charge, message_charge, workflow_charge, phone_number_charge, voice_generation_charge, rate_adjustment, deposits/auto-recharges, plan credits/debits. " +
32
+ "mode='summary' (default) groups by calendar month x type with count and total USD - use this to reconcile spend month over month (e.g. an effective per-minute rate change: compare call_charge totals against minutes from trillet_get_call_metrics, and check whether other charge types or a rate_adjustment explain the difference). mode='list' returns individual transactions, newest first, capped at `limit`.",
33
+ inputSchema: {
34
+ mode: z.enum(["summary", "list"]).default("summary"),
35
+ types: z
36
+ .array(z.string())
37
+ .optional()
38
+ .describe('Only include these transaction types, e.g. ["call_charge","sms_charge"]'),
39
+ from_date: z.string().optional().describe("ISO date, inclusive"),
40
+ to_date: z.string().optional().describe("ISO date, inclusive"),
41
+ limit: z.number().default(50).describe("mode='list' only: max transactions returned"),
42
+ ...workspaceParam,
43
+ },
44
+ annotations: { readOnlyHint: true, openWorldHint: true },
45
+ }, guard(async ({ mode, types, from_date, to_date, limit, workspace_id }) => {
46
+ // The API returns the full unpaginated ledger; filter and aggregate here.
47
+ const res = await client.request("GET", "/v1/api/billing/transactions", {
48
+ workspaceId: workspace_id,
49
+ });
50
+ let txs = Array.isArray(res) ? res : res.transactions ?? res.data ?? [];
51
+ const from = from_date ? new Date(from_date).getTime() : -Infinity;
52
+ const to = to_date ? new Date(to_date).getTime() + 86_399_999 : Infinity;
53
+ txs = txs.filter((t) => {
54
+ const ts = new Date(t.timestamp ?? t.createdAt).getTime();
55
+ return ts >= from && ts <= to && (!types || types.includes(t.type));
56
+ });
57
+ if (mode === "list") {
58
+ txs.sort((a, b) => new Date(b.timestamp ?? b.createdAt).getTime() -
59
+ new Date(a.timestamp ?? a.createdAt).getTime());
60
+ return ok({
61
+ totalMatching: txs.length,
62
+ transactions: txs.slice(0, limit).map((t) => ({
63
+ type: t.type,
64
+ amount: t.amount,
65
+ description: t.description,
66
+ timestamp: t.timestamp ?? t.createdAt,
67
+ })),
68
+ });
69
+ }
70
+ const buckets = {};
71
+ for (const t of txs) {
72
+ const d = new Date(t.timestamp ?? t.createdAt);
73
+ const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")} ${t.type}`;
74
+ buckets[key] ??= { count: 0, total: 0 };
75
+ buckets[key].count += 1;
76
+ buckets[key].total += t.amount ?? 0;
77
+ }
78
+ const summary = Object.entries(buckets)
79
+ .sort(([a], [b]) => a.localeCompare(b))
80
+ .map(([key, v]) => {
81
+ const [month, ...type] = key.split(" ");
82
+ return { month, type: type.join(" "), count: v.count, totalUsd: Number(v.total.toFixed(4)) };
83
+ });
84
+ return ok({ totalMatching: txs.length, monthlySummary: summary });
85
+ }));
86
+ }
@@ -0,0 +1,178 @@
1
+ import { z } from "zod";
2
+ import { ok, guard, workspaceParam } from "../helpers.js";
3
+ const ttsModelSchema = z
4
+ .object({
5
+ provider: z.enum(["rime", "elevenlabs", "cartesia", "openai"]).describe("TTS provider"),
6
+ voiceId: z.string().describe('Voice id, e.g. "arcana_eucalyptus" (Rime Australian voice)'),
7
+ name: z.string().describe("Human-readable voice name shown in the dashboard"),
8
+ language: z.string().default("en"),
9
+ rimeModel: z.string().optional().describe('Rime model family, e.g. "coda", "arcana", "mistv3"'),
10
+ })
11
+ .describe("Text-to-speech voice configuration");
12
+ function summarizeAgent(a) {
13
+ return {
14
+ id: a._id,
15
+ name: a.name,
16
+ llmModel: a.llmModel,
17
+ voice: a.ttsModel ? `${a.ttsModel.provider}/${a.ttsModel.voiceId}` : null,
18
+ stt: a.sttProvider?.id,
19
+ callFlowId: a.pathway,
20
+ callFlowName: a.flowName,
21
+ phoneNumbers: (a.phoneNumberIds ?? []).length,
22
+ updatedAt: a.updatedAt,
23
+ };
24
+ }
25
+ export function registerAgentTools(server, client) {
26
+ server.registerTool("trillet_list_agents", {
27
+ title: "List agents",
28
+ description: "List all voice agents in the workspace with a compact summary (id, name, LLM, voice, linked call flow).",
29
+ inputSchema: { ...workspaceParam },
30
+ annotations: { readOnlyHint: true, openWorldHint: true },
31
+ }, guard(async ({ workspace_id }) => {
32
+ const agents = await client.request("GET", "/v1/api/agents", {
33
+ workspaceId: workspace_id,
34
+ });
35
+ return ok(agents.map(summarizeAgent));
36
+ }));
37
+ server.registerTool("trillet_get_agent", {
38
+ title: "Get agent",
39
+ description: "Fetch one agent's full configuration by id.",
40
+ inputSchema: { agent_id: z.string(), ...workspaceParam },
41
+ annotations: { readOnlyHint: true, openWorldHint: true },
42
+ }, guard(async ({ agent_id, workspace_id }) => {
43
+ const agents = await client.request("GET", "/v1/api/agents", {
44
+ workspaceId: workspace_id,
45
+ });
46
+ const agent = agents.find((a) => a._id === agent_id);
47
+ if (!agent)
48
+ return ok({ error: `Agent ${agent_id} not found in workspace` });
49
+ return ok(agent);
50
+ }));
51
+ server.registerTool("trillet_create_agent", {
52
+ title: "Create agent",
53
+ description: "Create a new voice agent. ttsModel is REQUIRED by the API (requests without it fail with a 500). " +
54
+ "Creating an agent usually also creates a linked call flow; check the response's pathway field. " +
55
+ 'Sensible defaults: Rime voice, llmModel "platform-llm".',
56
+ inputSchema: {
57
+ name: z.string().describe("Agent display name"),
58
+ // Brand-free by design (TRI-835): llmModel is returned on the public v1
59
+ // API, so whitelabel customers see this string. The example matters as
60
+ // much as the default here, since the model copies it.
61
+ llmModel: z
62
+ .string()
63
+ .default("platform-llm")
64
+ .describe('LLM id, e.g. "platform-llm", "gemini-2.5-flash", "gpt-5.2"'),
65
+ ttsModel: ttsModelSchema,
66
+ ...workspaceParam,
67
+ },
68
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
69
+ }, guard(async ({ name, llmModel, ttsModel, workspace_id }) => {
70
+ const res = await client.request("POST", "/v1/api/agents", {
71
+ body: { name, llmModel, ttsModel },
72
+ workspaceId: workspace_id,
73
+ });
74
+ return ok(res);
75
+ }));
76
+ server.registerTool("trillet_update_agent", {
77
+ title: "Update agent",
78
+ description: "Update an agent's name, LLM model, voice, settings, or TTS pronunciation dictionary. Only send the fields you want to change. " +
79
+ "ttsDictionary is the agent-level pronunciation list (ElevenLabs voices): the API syncs it to the voice provider and manages the dictionary locator ids itself; send only words.",
80
+ inputSchema: {
81
+ agent_id: z.string(),
82
+ name: z.string().optional(),
83
+ llmModel: z.string().optional(),
84
+ ttsModel: ttsModelSchema.optional(),
85
+ ttsDictionary: z
86
+ .object({
87
+ words: z
88
+ .array(z.object({
89
+ word: z.string().describe("The word or phrase to override pronunciation for"),
90
+ alias: z.string().describe("How it should be pronounced (phonetic spelling or an alternate word)"),
91
+ }))
92
+ .describe("COMPLETE list; replaces the agent's existing words"),
93
+ })
94
+ .optional()
95
+ .describe("Agent-level TTS pronunciation dictionary (agent.ttsDictionary). Applies to ElevenLabs voices; other providers keep the words but do not sync them."),
96
+ settings: z
97
+ .object({
98
+ speed: z.number().optional().describe("Voice speed, e.g. 0.94"),
99
+ volume: z.number().optional(),
100
+ temperature: z.number().optional(),
101
+ })
102
+ .optional(),
103
+ ...workspaceParam,
104
+ },
105
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
106
+ }, guard(async ({ agent_id, workspace_id, ...fields }) => {
107
+ const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
108
+ const res = await client.request("PUT", `/v1/api/agents/${agent_id}`, {
109
+ body,
110
+ workspaceId: workspace_id,
111
+ });
112
+ return ok(res);
113
+ }));
114
+ server.registerTool("trillet_delete_agent", {
115
+ title: "Delete agent",
116
+ description: "Delete an agent from the workspace. Unbind its phone numbers first (a bound number stops routing). Always confirm with the user; requires confirm=true. The linked call flow is a separate document and is not deleted.",
117
+ inputSchema: {
118
+ agent_id: z.string(),
119
+ confirm: z
120
+ .boolean()
121
+ .describe("Must be true. Only set after the user explicitly approved deleting this agent."),
122
+ ...workspaceParam,
123
+ },
124
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
125
+ }, guard(async ({ agent_id, confirm, workspace_id }) => {
126
+ if (!confirm) {
127
+ return ok({
128
+ blocked: true,
129
+ message: "Refusing to delete: confirm=false. Ask the user to approve deleting agent " +
130
+ agent_id +
131
+ " and retry with confirm=true.",
132
+ });
133
+ }
134
+ const res = await client.request("DELETE", `/v1/api/agents/${agent_id}`, {
135
+ workspaceId: workspace_id,
136
+ });
137
+ return ok(res ?? { deleted: agent_id });
138
+ }));
139
+ server.registerTool("trillet_search_voices", {
140
+ title: "Search voices",
141
+ description: "Search the built-in voice catalog across providers (Rime, ElevenLabs, Cartesia, OpenAI, Hume). All filters optional; with no filters returns the full catalog (trimmed to `limit`). Use the returned provider + voiceId in an agent's ttsModel.",
142
+ inputSchema: {
143
+ name: z.string().optional(),
144
+ provider: z.string().optional(),
145
+ gender: z.string().optional(),
146
+ accent: z.string().optional().describe('e.g. "australian", "british", "american"'),
147
+ category: z.string().optional(),
148
+ age: z.string().optional(),
149
+ use_case: z.string().optional(),
150
+ limit: z.number().default(50).describe("Max results returned (applied client-side)"),
151
+ ...workspaceParam,
152
+ },
153
+ annotations: { readOnlyHint: true, openWorldHint: true },
154
+ }, guard(async ({ name, provider, gender, accent, category, age, use_case, limit, workspace_id }) => {
155
+ const filters = { name, provider, gender, accent, category, age, useCase: use_case };
156
+ // Always the search endpoint: with no filters it returns the whole flat
157
+ // catalog. GET /voices returns a provider-keyed object, which is why the
158
+ // no-filter path used to come back empty.
159
+ const res = await client.request("POST", "/v1/api/agents/voices/search", {
160
+ body: Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== undefined)),
161
+ workspaceId: workspace_id,
162
+ });
163
+ const list = Array.isArray(res) ? res : res.voices ?? res.data ?? [];
164
+ const trimmed = list.slice(0, limit).map((v) => ({
165
+ provider: v.provider,
166
+ voiceId: v.voiceId ?? v.id ?? v._id,
167
+ name: v.name,
168
+ gender: v.gender,
169
+ accent: v.accent,
170
+ language: v.language,
171
+ category: v.category,
172
+ age: v.age,
173
+ useCase: v.useCase,
174
+ model: v.rimeModel ?? v.model,
175
+ }));
176
+ return ok({ total: list.length, returned: trimmed.length, voices: trimmed });
177
+ }));
178
+ }