@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,223 @@
1
+ /**
2
+ * Interactive text test calls: hold a turn-by-turn TEXT conversation with a
3
+ * real agent, exactly like the dashboard Test panel in text mode.
4
+ *
5
+ * Verified live against production (2026-08-27/28):
6
+ * - POST /v1/api/calls/web/connect with mode:"text" starts a text-mode web
7
+ * call for BOTH flow kinds — simple prompt flows and node flows. Node flows
8
+ * run the REAL runtime over text: typed turns go through extraction and the
9
+ * live router (unlike trillet_simulate_node_graph, whose router is mocked).
10
+ * - The reply does NOT include the LiveKit ws URL (the dashboard reads it
11
+ * from build-time env), hence the host map below.
12
+ * - Text calls are real calls: they create a CallHistory row (transcript,
13
+ * post-call analysis, cost — LLM only, no TTS/telephony) and REAL side
14
+ * effects run (bookings, webhooks, transfers). stub_incall_apis only stubs
15
+ * node-flow In-Call API nodes with their configured testResponse.
16
+ * - Text mode relaxes the silence timers (>=90s before the "are you there?"
17
+ * nudge), so normal thinking time between tool calls is safe.
18
+ */
19
+ import { z } from "zod";
20
+ import { ok, guard, workspaceParam } from "../helpers.js";
21
+ import { ensureRtcNode, rtcNodeInstalled, depsDir } from "../textcall/deps.js";
22
+ import { createSession, getSession, unknownSession, activeSessionCount, collectAgentTurns, endSession, rpc, } from "../textcall/session.js";
23
+ /**
24
+ * LiveKit ws host per backend. The connect response omits it, so mirror the
25
+ * dashboard's build-time env. Override with TRILLET_LIVEKIT_URL for other
26
+ * environments; a `connection.url` in the API response (future backend
27
+ * addition) always wins.
28
+ */
29
+ const LIVEKIT_URLS = {
30
+ "api.trillet.ai": "wss://trillet-ai-xdx0dw5r.livekit.cloud",
31
+ "api-trillet.verlatai.com": "wss://trilletai-ek0j3un1.livekit.cloud",
32
+ localhost: "wss://trilletai-ek0j3un1.livekit.cloud",
33
+ "127.0.0.1": "wss://trilletai-ek0j3un1.livekit.cloud",
34
+ };
35
+ function resolveLivekitUrl(baseUrl, connectRes) {
36
+ if (connectRes?.connection?.url)
37
+ return connectRes.connection.url;
38
+ if (process.env.TRILLET_LIVEKIT_URL)
39
+ return process.env.TRILLET_LIVEKIT_URL;
40
+ const host = new URL(baseUrl).hostname;
41
+ const url = LIVEKIT_URLS[host];
42
+ if (!url) {
43
+ throw new Error(`Don't know the LiveKit URL for backend host '${host}'. Set TRILLET_LIVEKIT_URL to the environment's wss:// LiveKit URL.`);
44
+ }
45
+ return url;
46
+ }
47
+ // Concurrency caps. The session registry is process-wide, so in an embedded
48
+ // multi-tenant host (the dashboard assistant backend) the instance cap guards
49
+ // worker memory while the per-workspace cap keeps one tenant from starving the
50
+ // rest. The stdio MCP server is single-user, where the default instance cap of
51
+ // 4 acts exactly as before; embedders raise it via TRILLET_MCP_MAX_TEXT_CALLS.
52
+ const MAX_ACTIVE_SESSIONS = Number(process.env.TRILLET_MCP_MAX_TEXT_CALLS || 4);
53
+ const MAX_ACTIVE_PER_WORKSPACE = Number(process.env.TRILLET_MCP_MAX_TEXT_CALLS_PER_WORKSPACE || 2);
54
+ function sessionSummary(s) {
55
+ return {
56
+ session_id: s.id,
57
+ agent: s.agentName ?? s.agentId,
58
+ room_name: s.roomName,
59
+ call_history_id: s.callHistoryId,
60
+ ended: s.ended,
61
+ ...(s.endReason ? { end_reason: s.endReason } : {}),
62
+ };
63
+ }
64
+ export function registerTextCallTools(server, client) {
65
+ server.registerTool("trillet_start_text_call", {
66
+ title: "Start a text test call",
67
+ description: "Start an interactive TEXT conversation with an agent — the same thing as the dashboard Test panel in text mode. Works for both flow kinds (simple prompt flows and node flows; node flows run the real router and variable extraction). Returns a session_id plus the agent's greeting; continue with trillet_say and finish with trillet_end_text_call. Notes: this is a real call (CallHistory row, LLM cost of a few cents, transcript + post-call analysis) and non-stubbed side effects (bookings, webhooks, transfers) EXECUTE FOR REAL. Pass flow_id to test that flow's ACTIVE EDITOR DRAFT when one exists (exactly like the Test panel while editing). First ever use installs a native LiveKit client (~30-60s, needs npm).",
68
+ inputSchema: {
69
+ agent_id: z.string().optional().describe("Agent to talk to. Omit only if flow_id is given (the flow's agent is used)."),
70
+ flow_id: z
71
+ .string()
72
+ .optional()
73
+ .describe("Call-flow (pathway) id. When provided it is passed like the editor Test panel does: if the flow has an active draft, the DRAFT config is tested instead of production."),
74
+ dynamic_variables: z
75
+ .record(z.string(), z.string())
76
+ .optional()
77
+ .describe("Per-call variables substituted into the prompt as {{key}}"),
78
+ direction: z
79
+ .enum(["inbound", "outbound"])
80
+ .optional()
81
+ .describe("Test as this call direction (welcome message + prompt behave accordingly)"),
82
+ stub_incall_apis: z
83
+ .boolean()
84
+ .default(true)
85
+ .describe("Node flows only: In-Call API nodes return their configured testResponse instead of hitting the real endpoint (the editor Test panel default). Other tools still run for real."),
86
+ ...workspaceParam,
87
+ },
88
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
89
+ }, guard(async ({ agent_id, flow_id, dynamic_variables, direction, stub_incall_apis, workspace_id }) => {
90
+ if (!agent_id && !flow_id)
91
+ throw new Error("Provide agent_id or flow_id.");
92
+ const sessionWorkspace = workspace_id || client.workspaceId;
93
+ if (activeSessionCount(sessionWorkspace) >= MAX_ACTIVE_PER_WORKSPACE) {
94
+ throw new Error(`This workspace already has ${MAX_ACTIVE_PER_WORKSPACE} active text calls. End one with trillet_end_text_call first.`);
95
+ }
96
+ if (activeSessionCount() >= MAX_ACTIVE_SESSIONS) {
97
+ throw new Error('The platform is at its text-call capacity right now. Try again in a minute.');
98
+ }
99
+ // Resolve the agent from the flow when only flow_id was given.
100
+ let agentId = agent_id;
101
+ if (!agentId) {
102
+ const flow = await client.request("GET", `/v1/api/call-flows/${flow_id}`, {
103
+ workspaceId: workspace_id,
104
+ });
105
+ const p = flow.pathway ?? flow;
106
+ agentId = p.agent?._id ?? p.agent ?? p.agentId;
107
+ if (!agentId)
108
+ throw new Error(`Call flow ${flow_id} has no agent attached.`);
109
+ }
110
+ // First use: install the native LiveKit client into ~/.trillet-mcp.
111
+ if (!rtcNodeInstalled())
112
+ await ensureRtcNode();
113
+ const body = { agentId, mode: "text" };
114
+ if (dynamic_variables && Object.keys(dynamic_variables).length)
115
+ body.dynamic_variables = dynamic_variables;
116
+ if (flow_id)
117
+ body.pathwayId = flow_id;
118
+ if (direction)
119
+ body.test_direction = direction;
120
+ if (stub_incall_apis)
121
+ body.isTestCall = true;
122
+ const res = await client.request("POST", "/v1/api/calls/web/connect", {
123
+ body,
124
+ workspaceId: workspace_id,
125
+ });
126
+ const url = resolveLivekitUrl(client.baseUrl, res);
127
+ const session = createSession({
128
+ // Default: re-fork this same bundle (index.ts dispatches on the env
129
+ // flag). Embedders that aren't the stdio server (e.g. the backend AI
130
+ // assistant) point this at a tiny script that calls runTextCallWorker.
131
+ selfPath: process.env.TRILLET_MCP_TEXTCALL_WORKER_PATH || process.argv[1],
132
+ agentId: String(agentId),
133
+ agentName: res.agent?.name ?? null,
134
+ roomName: res.roomName,
135
+ callId: res.callId ?? null,
136
+ callHistoryId: res.callHistoryId ?? null,
137
+ workspaceId: sessionWorkspace,
138
+ });
139
+ try {
140
+ await rpc(session, "connect", { url, token: res.token }, 20_000);
141
+ }
142
+ catch (e) {
143
+ await endSession(session, "connect_failed");
144
+ throw e;
145
+ }
146
+ // The agent joins ~0.5s after room creation and greets ~2-4s in.
147
+ const greeting = await collectAgentTurns(session, { timeoutMs: 60_000 });
148
+ return ok({
149
+ ...sessionSummary(session),
150
+ greeting: greeting.turns,
151
+ ...(greeting.timedOut ? { warning: "No greeting within 60s — the agent may be aiFirst-disabled or failing; try trillet_say anyway or check trillet_get_call." } : {}),
152
+ next: "Send caller turns with trillet_say; hang up with trillet_end_text_call.",
153
+ });
154
+ }));
155
+ server.registerTool("trillet_say", {
156
+ title: "Say something in a text test call",
157
+ description: "Send one caller message in an active text test call and wait for the agent's reply (usually ~1-3s; up to 90s when the agent runs tools). Returns the agent's message(s) plus call_ended when the agent hung up (end_call / transfer / silence policy). Any messages the agent sent unprompted while you were away are returned in unprompted_messages.",
158
+ inputSchema: {
159
+ session_id: z.string(),
160
+ text: z.string().describe("What the caller says"),
161
+ },
162
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
163
+ }, guard(async ({ session_id, text }) => {
164
+ const s = getSession(session_id);
165
+ if (!s)
166
+ throw unknownSession(session_id);
167
+ if (s.ended) {
168
+ return ok({
169
+ ...sessionSummary(s),
170
+ call_ended: true,
171
+ note: "The call already ended; use trillet_end_text_call for the transcript or start a new call.",
172
+ });
173
+ }
174
+ // Nudges the agent sent while idle ("are you still there?") queue up;
175
+ // surface them separately so they aren't mistaken for the reply.
176
+ const unprompted = s.queue.splice(0);
177
+ s.transcript.push({ role: "caller", text, at: new Date().toISOString() });
178
+ s.lastActivityAt = Date.now();
179
+ await rpc(s, "say", { text }, 10_000);
180
+ const reply = await collectAgentTurns(s, { timeoutMs: 90_000 });
181
+ return ok({
182
+ session_id: s.id,
183
+ ...(unprompted.length ? { unprompted_messages: unprompted } : {}),
184
+ agent_messages: reply.turns,
185
+ ...(reply.timedOut ? { warning: "No reply within 90s. The call is still open — retry trillet_say or end the call." } : {}),
186
+ call_ended: s.ended,
187
+ ...(s.ended ? { end_reason: s.endReason, note: "Agent ended the call. trillet_end_text_call returns the full transcript." } : {}),
188
+ });
189
+ }));
190
+ server.registerTool("trillet_end_text_call", {
191
+ title: "End a text test call",
192
+ description: "Hang up a text test call (or clean up one the agent already ended) and get the full transcript. Post-call analysis and summary land on the call record ~30-60s later — fetch them with trillet_get_call using call_history_id.",
193
+ inputSchema: {
194
+ session_id: z.string(),
195
+ },
196
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
197
+ }, guard(async ({ session_id }) => {
198
+ const s = getSession(session_id);
199
+ if (!s)
200
+ throw unknownSession(session_id);
201
+ const alreadyEnded = s.ended;
202
+ await endSession(s, alreadyEnded ? s.endReason : "ended_by_tester");
203
+ if (!alreadyEnded) {
204
+ // Same room teardown the dashboard panel performs on hang-up.
205
+ await client
206
+ .request("POST", "/v1/api/calls/web/disconnect", {
207
+ body: { roomName: s.roomName },
208
+ workspaceId: s.workspaceId,
209
+ })
210
+ .catch(() => undefined);
211
+ }
212
+ return ok({
213
+ ...sessionSummary(s),
214
+ transcript: s.transcript.map((t) => `[${t.role}] ${t.text}`),
215
+ turns: s.transcript.length,
216
+ tip: s.callHistoryId
217
+ ? `Analysis/summary/cost: trillet_get_call call_id=${s.callHistoryId} (ready ~30-60s after the call ends).`
218
+ : "No call_history_id was returned for this call.",
219
+ });
220
+ }));
221
+ }
222
+ /** Exported for troubleshooting messages elsewhere. */
223
+ export const textCallDepsDir = depsDir;
@@ -0,0 +1,415 @@
1
+ import { z } from "zod";
2
+ import { ok, guard, workspaceParam } from "../helpers.js";
3
+ const WORKFLOW_REFERENCE = `# Trillet Workflow Reference (verified against the backend)
4
+
5
+ Workflows are trigger-driven automation graphs (schemaVersion 2). Lifecycle: create (draft) ->
6
+ test -> activate -> runs. Drafts and paused workflows NEVER fire from real triggers.
7
+
8
+ ## Triggers (7 types) and their {{trigger.*}} payloads
9
+ - inbound_call_completed / outbound_call_completed: phone, disposition, duration, summary,
10
+ transcript (flattened "role: text" lines), recordingUrl, pathwayId, analyzedData.<field>,
11
+ responseVariables.<field>. Web/dashboard test calls fire the OUTBOUND trigger.
12
+ - webhook: whole request body + normalized phone/email/full_name. Two URL styles: per-workflow
13
+ /v1/api/workflow-webhook/<webhookToken>, or the workspace-shared endpoint (requires
14
+ trigger.config.listenMode:"workspace"). Last payload is captured even for drafts.
15
+ - schedule: trigger.config.cron (5/6-field or @daily style) + trigger.config.timezone
16
+ (IANA; falls back to settings.timezone then UTC). Payload: source, scheduledAt, lastRunAt.
17
+ No contact fields, so per-contact dedup is skipped.
18
+ - inbound_message: phone, body, message, channel.
19
+ - ghl_new_contact: phone, email, full_name, tags[]. meta_ads_lead: phone, email, full_name,
20
+ first_name, last_name, ad_name, page_id, form_id.
21
+
22
+ ## trigger.config extras
23
+ - businessHours: { mode: "after_hours" (run only when closed) | "during_hours" | "none",
24
+ timezone (REQUIRED when mode set; missing tz = silently never runs),
25
+ perDay: { Monday..Sunday: {enabled,start,end} }, blockedDates: ["yyyy-MM-dd"] }.
26
+ There is NO "holidays" key; use blockedDates.
27
+ - contactMapping: { phoneFields[], emailFields[], nameFields[], lastNameFields[],
28
+ defaultCountry } - ordered fallbacks mapping raw payload fields to phone/email/full_name,
29
+ applied BEFORE filters (phone normalized to E.164).
30
+
31
+ ## trigger.filters
32
+ [{field (dot-path into payload, e.g. "pathwayId"), operator, value}]. ALL must pass.
33
+ Operators (ONLY these 8): equals, not_equals, equals_ci, not_equals_ci, contains,
34
+ not_contains, is_empty, is_not_empty. gt/lt are NOT valid on trigger filters.
35
+
36
+ SCOPING TO ONE AGENT: there is no agentIds field. A call-completed workflow is tied to a
37
+ single flow ONLY by a filter row {field: "pathwayId", operator: "equals", value: <pathway id>}.
38
+ With no such row it fires after EVERY agent's calls in the workspace, which is rarely what
39
+ someone means by "a workflow for this agent" - always add the filter unless workspace-wide is
40
+ intended. Note the id mismatch: this filter takes a PATHWAY (call flow) id, while a
41
+ make_ai_call step takes an AGENT id. Get both from the call-flow/node-flow get tools.
42
+
43
+ ## Step types (15) and their config
44
+ Common: { stepId (unique), type, name, config, onError: stop|continue|retry (retry uses
45
+ maxRetries, default 3) }.
46
+ - send_sms: { to (default {{trigger.phone}}), message, from (REQUIRED phone number owned by the
47
+ workspace; "pool" is rejected) }. Message <=1600 chars, must not start with [ or {, and must
48
+ not contain {{trigger.transcript}} or {{trigger.analyzedData}}. Needs wallet balance > 0.
49
+ - send_email: { to (default {{trigger.email}}; multi-recipient: comma/semicolon/newline
50
+ separated or array, "Name <a@x.com>" ok), subject, body (HTML), replyTo }. Sender = workspace
51
+ email connection / verified domain, else Trillet default.
52
+ - send_email_to_group: { routeVariable (a trigger field name, e.g. an analyzedData field),
53
+ routes: [{matchValue, recipients[], subject, body}], defaultRoute {recipients[],subject,body} }.
54
+ Routes on {{trigger.<routeVariable>}} case-insensitively; no match + no defaultRoute = no-op.
55
+ - make_ai_call: { agentId (REQUIRED), to (default {{trigger.phone}}), from (must be one of the
56
+ agent's numbers), dynamicVariables ({k:v} or [{key,value}]), waitForCompletion,
57
+ completionTimeout + completionTimeoutUnit (default 2h) }. waitForCompletion pauses the run
58
+ until the call ends; read results via {{steps.<id>.<callResult field>}}.
59
+ - wait_delay: { duration, unit: seconds|minutes|hours|days (default minutes) }.
60
+ - wait_for_reply: { timeout, timeoutUnit }. Waits for an inbound SMS from the run's contact.
61
+ Output: { outcome: "reply_received"|"timed_out", reply_text, received_at }.
62
+ - send_webhook: { url, method (default POST), headers (object or [{key,value}]),
63
+ body (JSON, template-resolved), responseMapping {varName:"a.b.c"} }. GET sends body as query
64
+ params. Private/loopback IPs are blocked. Secrets: {{_secret.<name>}} or
65
+ {{_secret.<name>.<field>}} (workspace secrets; ONLY valid in send_webhook url/headers/body).
66
+ - ghl_create_contact / ghl_update_contact: need the workflow's GHL integration connected via the
67
+ dashboard first. create: {firstName,lastName,email,phone,tags,customFields};
68
+ update: {lookupField (default {{trigger.phone}}), fields{}}.
69
+ - condition: { matchType: "all"|"any", conditions: [{field, operator, value}] }. Operators: the
70
+ 8 filter operators PLUS gt, gte, lt, lte. Output branch "yes"/"no" (an edge without
71
+ sourceHandle counts as "yes"). If the taken branch has no edge and the check failed, the run
72
+ ends with status "filtered".
73
+ - switch: { branches: [{id, label, matchType, conditions[]}] }. First matching branch wins;
74
+ edges use sourceHandle=<branch id> plus an optional "default" edge.
75
+ - set_variables: { assignments: [{name, value}] }. Read downstream as {{steps.<stepId>.<name>}}.
76
+ Assignments do not see each other.
77
+ - google_sheets: { googleConnectionId, spreadsheetId, worksheetName, operation: "append"|"upsert",
78
+ matchColumn (upsert only, must be a mapped column), columnMappings: [{column, value}] }.
79
+ Google connection is set up in the dashboard.
80
+ - code: { code (JavaScript, REQUIRED), timeoutMs (1000-30000) }. Deployed to Cloudflare Workers
81
+ when the workflow is SAVED; activation FAILS (502) if the deploy fails. Receives
82
+ variables = {trigger, steps, item, system} and a secrets object; must return a plain object
83
+ whose keys become {{steps.<id>.*}} outputs. Runs FOR REAL in test mode.
84
+ - for_each: { items (template resolving to an array, e.g. {{steps.x.list}}), maxItems (REQUIRED,
85
+ 1-1000), contact {phone,email,name} (may use {{item.*}}) }. Needs an "each" edge (loop body,
86
+ run as a child run per item with {{item.*}} available) and a "done" edge (parent continues
87
+ immediately; it does NOT wait for children). No nested for_each.
88
+
89
+ ## Edges / graph rules
90
+ [{id, source (stepId or "trigger"), target, sourceHandle?}]. sourceHandle: condition "yes"/"no";
91
+ switch <branch id>/"default"; for_each "each"/"done"; ALL other step types must omit it.
92
+ Max ONE outgoing edge per handle (no fan-out). No cycles. Every step must be reachable from the
93
+ entry step or activation fails.
94
+
95
+ ## settings
96
+ { timezone (default Australia/Sydney; only used as schedule-tz fallback), maxConcurrentRuns
97
+ (default 50; excess runs queue), maxRunsPerContact (default 1 !) + maxRunsPerContactWindow
98
+ (default 86400s) - repeat calls from the same number are SILENTLY skipped when exceeded,
99
+ deduplicationWindow (default 60s) }. Contact identity = trigger phone else email; no contact =
100
+ limits skipped.
101
+
102
+ ## Template system
103
+ Roots: {{trigger.*}}, {{steps.<stepId>.<field>}} (auto-aliases into .output/.callResult),
104
+ {{item.*}} (inside for_each children only), {{system.now|runId|workflowId|workspaceName}}.
105
+ Interpolating a whole object/array renders '' - only leaf fields interpolate.
106
+ Functions: if, ifempty, upper, lower, concat, replace, switch, formatDate, parseDate, dateAdd,
107
+ addDays, startOf, endOf, now, normalizePhone, digits, random, substring, length, gt, gte, lt,
108
+ lte, eq, and, or, not, add, sub, inTimeWindow. No nested {{ }}.
109
+
110
+ ## Testing vs running
111
+ - test (works on drafts): SIMULATES send_sms, send_email(_to_group), make_ai_call, send_webhook,
112
+ ghl_*, google_sheets (returns wouldSend previews); delays resolve instantly. condition, switch,
113
+ set_variables, for_each and CODE run for real.
114
+ - run (active only): full real execution, side effects included.
115
+
116
+ ## Billing
117
+ Completed runs are metered against plan allotments (Basic 500 / Studio 2000 / Agency 10000 per
118
+ month, Enterprise unmetered) with wallet overage per extra run. for_each children each count.`;
119
+ const stepSchema = z.object({
120
+ stepId: z.string().describe("Unique step id within the workflow"),
121
+ type: z
122
+ .enum([
123
+ "send_sms",
124
+ "send_email",
125
+ "send_email_to_group",
126
+ "make_ai_call",
127
+ "wait_delay",
128
+ "wait_for_reply",
129
+ "send_webhook",
130
+ "ghl_create_contact",
131
+ "ghl_update_contact",
132
+ "condition",
133
+ "switch",
134
+ "set_variables",
135
+ "google_sheets",
136
+ "code",
137
+ "for_each",
138
+ ])
139
+ .describe("Step type; per-type config fields are in trillet_workflow_reference"),
140
+ name: z.string().optional(),
141
+ config: z.record(z.string(), z.unknown()).default({}),
142
+ onError: z.enum(["stop", "continue", "retry"]).default("stop"),
143
+ maxRetries: z.number().optional().describe("Retry attempts when onError='retry' (default 3)"),
144
+ });
145
+ const edgeSchema = z.object({
146
+ id: z.string(),
147
+ source: z.string().describe("stepId or the literal 'trigger'"),
148
+ target: z.string(),
149
+ sourceHandle: z
150
+ .string()
151
+ .optional()
152
+ .describe("'yes'/'no' for condition, branch id or 'default' for switch, 'each'/'done' for for_each; MUST be omitted for all other step types"),
153
+ });
154
+ const triggerTypes = z.enum([
155
+ "inbound_call_completed",
156
+ "outbound_call_completed",
157
+ "webhook",
158
+ "schedule",
159
+ "inbound_message",
160
+ "ghl_new_contact",
161
+ "meta_ads_lead",
162
+ ]);
163
+ function summarizeWorkflow(w) {
164
+ return {
165
+ id: w._id,
166
+ name: w.name,
167
+ status: w.status,
168
+ trigger: w.trigger?.type,
169
+ filters: w.trigger?.filters,
170
+ steps: (w.steps ?? []).map((s) => `${s.stepId}(${s.type})`),
171
+ stats: w.stats,
172
+ };
173
+ }
174
+ function summarizeRun(r) {
175
+ return {
176
+ id: r._id,
177
+ status: r.status,
178
+ skipReason: r.skipReason,
179
+ contactPhone: r.contactPhone,
180
+ contactEmail: r.contactEmail,
181
+ currentStepId: r.currentStepId,
182
+ completedSteps: r.completedSteps,
183
+ totalSteps: r.totalSteps,
184
+ error: r.error,
185
+ testMode: r.testMode,
186
+ createdAt: r.createdAt,
187
+ finishedAt: r.finishedAt ?? r.completedAt,
188
+ };
189
+ }
190
+ export function registerWorkflowTools(server, client) {
191
+ server.registerTool("trillet_workflow_reference", {
192
+ title: "Workflow reference",
193
+ description: "Return the complete reference for Trillet automation workflows: all 7 trigger types with their payloads, all 15 step types with exact config fields, edge/graph rules, settings gotchas, the template/function system, test-vs-run semantics, and billing. READ THIS FIRST before creating or editing any workflow.",
194
+ inputSchema: {},
195
+ annotations: { readOnlyHint: true, openWorldHint: false },
196
+ }, guard(async () => ok(WORKFLOW_REFERENCE)));
197
+ server.registerTool("trillet_list_workflows", {
198
+ title: "List workflows",
199
+ description: "List automation workflows in the workspace with compact summaries.",
200
+ inputSchema: {
201
+ status: z.enum(["draft", "active", "paused", "archived"]).optional(),
202
+ trigger_type: triggerTypes.optional(),
203
+ search: z.string().optional().describe("Name search"),
204
+ page: z.number().default(1),
205
+ limit: z.number().default(20),
206
+ ...workspaceParam,
207
+ },
208
+ annotations: { readOnlyHint: true, openWorldHint: true },
209
+ }, guard(async ({ status, trigger_type, search, page, limit, workspace_id }) => {
210
+ const res = await client.request("GET", "/v1/api/workflows", {
211
+ query: { status, triggerType: trigger_type, search, page, limit },
212
+ workspaceId: workspace_id,
213
+ });
214
+ const list = Array.isArray(res) ? res : res.workflows ?? res.data ?? [];
215
+ return ok({ total: res.total, workflows: list.map(summarizeWorkflow) });
216
+ }));
217
+ server.registerTool("trillet_get_workflow", {
218
+ title: "Get workflow",
219
+ description: "Fetch one workflow: full trigger (type, config, filters), steps with configs, edges, settings, status, stats, and webhook token if any.",
220
+ inputSchema: { workflow_id: z.string(), ...workspaceParam },
221
+ annotations: { readOnlyHint: true, openWorldHint: true },
222
+ }, guard(async ({ workflow_id, workspace_id }) => {
223
+ const res = await client.request("GET", `/v1/api/workflows/${workflow_id}`, {
224
+ workspaceId: workspace_id,
225
+ });
226
+ return ok(res.workflow ?? res);
227
+ }));
228
+ server.registerTool("trillet_create_workflow", {
229
+ title: "Create workflow",
230
+ description: "Create an automation workflow (created as a draft; test with trillet_test_workflow, then trillet_activate_workflow). Consult trillet_workflow_reference for step configs, trigger payloads, and template variables before building. " +
231
+ "GOTCHA: settings.maxRunsPerContact defaults to 1 per 24h and silently skips repeat runs for the same contact; this tool defaults it to 500. Filter call-completed triggers to one flow with a filter on 'pathwayId'.",
232
+ inputSchema: {
233
+ name: z.string(),
234
+ description: z.string().optional(),
235
+ trigger_type: triggerTypes,
236
+ trigger_config: z
237
+ .record(z.string(), z.unknown())
238
+ .default({})
239
+ .describe("Trigger extras: schedule needs {cron, timezone}; webhook may set {listenMode:'workspace'}; optional businessHours gate {mode:'after_hours'|'during_hours', timezone, perDay, blockedDates} and contactMapping (see reference)"),
240
+ trigger_filters: z
241
+ .array(z.object({ field: z.string(), operator: z.string(), value: z.unknown() }))
242
+ .default([])
243
+ .describe('ALL must pass. Operators: equals, not_equals, equals_ci, not_equals_ci, contains, not_contains, is_empty, is_not_empty (gt/lt NOT valid here). e.g. [{"field":"pathwayId","operator":"equals","value":"<flow id>"}]'),
244
+ steps: z.array(stepSchema),
245
+ edges: z
246
+ .array(edgeSchema)
247
+ .describe("Graph edges; first edge usually {source:'trigger', target:<first stepId>}. Max one edge per handle, no cycles, all steps must be reachable."),
248
+ timezone: z.string().default("Australia/Sydney"),
249
+ max_runs_per_contact: z.number().default(500),
250
+ ...workspaceParam,
251
+ },
252
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
253
+ }, guard(async ({ name, description, trigger_type, trigger_config, trigger_filters, steps, edges, timezone, max_runs_per_contact, workspace_id, }) => {
254
+ const res = await client.request("POST", "/v1/api/workflows", {
255
+ body: {
256
+ name,
257
+ description,
258
+ trigger: { type: trigger_type, filters: trigger_filters, config: trigger_config },
259
+ steps,
260
+ edges,
261
+ schemaVersion: 2,
262
+ settings: {
263
+ timezone,
264
+ maxRunsPerContact: max_runs_per_contact,
265
+ maxRunsPerContactWindow: 86400,
266
+ deduplicationWindow: 5,
267
+ },
268
+ },
269
+ workspaceId: workspace_id,
270
+ });
271
+ return ok(summarizeWorkflow(res));
272
+ }));
273
+ server.registerTool("trillet_update_workflow", {
274
+ title: "Update workflow",
275
+ description: "Partial update of a workflow (name, trigger, steps, edges, settings). Steps and edges replace wholesale when provided. Active workflows keep running the updated definition. stats/webhookToken/ids are server-managed and ignored if sent.",
276
+ inputSchema: {
277
+ workflow_id: z.string(),
278
+ update: z.record(z.string(), z.unknown()),
279
+ ...workspaceParam,
280
+ },
281
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
282
+ }, guard(async ({ workflow_id, update, workspace_id }) => {
283
+ const res = await client.request("PUT", `/v1/api/workflows/${workflow_id}`, {
284
+ body: update,
285
+ workspaceId: workspace_id,
286
+ });
287
+ return ok(summarizeWorkflow(res));
288
+ }));
289
+ server.registerTool("trillet_activate_workflow", {
290
+ title: "Activate workflow",
291
+ description: "Activate a draft or paused workflow so its trigger starts firing. Fails if: no trigger or no steps, invalid cron (schedules), unreachable steps, or a code step that fails to deploy to Cloudflare (502).",
292
+ inputSchema: { workflow_id: z.string(), ...workspaceParam },
293
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
294
+ }, guard(async ({ workflow_id, workspace_id }) => {
295
+ // The API 400s on an empty body with a JSON content-type; the client
296
+ // always sends {} so this is safe.
297
+ const res = await client.request("POST", `/v1/api/workflows/${workflow_id}/activate`, {
298
+ workspaceId: workspace_id,
299
+ });
300
+ return ok({ id: res._id, status: res.status });
301
+ }));
302
+ server.registerTool("trillet_pause_workflow", {
303
+ title: "Pause workflow",
304
+ description: "Pause an active workflow: its trigger stops firing (schedules are unregistered). Reactivate with trillet_activate_workflow.",
305
+ inputSchema: { workflow_id: z.string(), ...workspaceParam },
306
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
307
+ }, guard(async ({ workflow_id, workspace_id }) => {
308
+ const res = await client.request("POST", `/v1/api/workflows/${workflow_id}/pause`, {
309
+ workspaceId: workspace_id,
310
+ });
311
+ return ok({ id: res._id, status: res.status });
312
+ }));
313
+ server.registerTool("trillet_delete_workflow", {
314
+ title: "Delete workflow",
315
+ description: "Delete a workflow (soft delete). Cancels its running/waiting/queued runs and unregisters its schedule. Always confirm with the user first; requires confirm=true.",
316
+ inputSchema: {
317
+ workflow_id: z.string(),
318
+ confirm: z
319
+ .boolean()
320
+ .describe("Must be true. Only set after the user explicitly approved deleting this workflow."),
321
+ ...workspaceParam,
322
+ },
323
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
324
+ }, guard(async ({ workflow_id, confirm, workspace_id }) => {
325
+ if (!confirm) {
326
+ return ok({
327
+ blocked: true,
328
+ message: "Refusing to delete: confirm=false. Ask the user to approve deleting workflow " +
329
+ workflow_id +
330
+ " and retry with confirm=true.",
331
+ });
332
+ }
333
+ const res = await client.request("DELETE", `/v1/api/workflows/${workflow_id}`, {
334
+ workspaceId: workspace_id,
335
+ });
336
+ return ok(res ?? { deleted: workflow_id });
337
+ }));
338
+ server.registerTool("trillet_test_workflow", {
339
+ title: "Test workflow (simulated)",
340
+ description: "Run a workflow in test mode with a sample trigger payload. Works on drafts. SMS/email/calls/webhooks/sheets/GHL are SIMULATED (returns wouldSend previews) and delays resolve instantly - but condition/switch/set_variables/for_each and CODE steps run for real. Returns the run id; inspect with trillet_get_workflow_run.",
341
+ inputSchema: {
342
+ workflow_id: z.string(),
343
+ trigger_data: z
344
+ .record(z.string(), z.unknown())
345
+ .default({})
346
+ .describe('Sample trigger payload, e.g. {"phone":"+61412345678","analyzedData":{"outcome":"booked"}}'),
347
+ ...workspaceParam,
348
+ },
349
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
350
+ }, guard(async ({ workflow_id, trigger_data, workspace_id }) => {
351
+ const res = await client.request("POST", `/v1/api/workflows/${workflow_id}/test`, {
352
+ body: { triggerData: trigger_data },
353
+ workspaceId: workspace_id,
354
+ });
355
+ return ok(res);
356
+ }));
357
+ server.registerTool("trillet_run_workflow", {
358
+ title: "Run workflow now (REAL side effects)",
359
+ description: "Trigger a REAL run of an ACTIVE workflow immediately: SMS, emails, AI calls, and webhooks actually fire and consume credits. Always confirm with the user first; requires confirm=true. For a dry run use trillet_test_workflow instead.",
360
+ inputSchema: {
361
+ workflow_id: z.string(),
362
+ trigger_data: z
363
+ .record(z.string(), z.unknown())
364
+ .default({})
365
+ .describe("Trigger payload for the run (fields readable as {{trigger.*}})"),
366
+ confirm: z
367
+ .boolean()
368
+ .describe("Must be true. Only set after the user explicitly approved a real run."),
369
+ ...workspaceParam,
370
+ },
371
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
372
+ }, guard(async ({ workflow_id, trigger_data, confirm, workspace_id }) => {
373
+ if (!confirm) {
374
+ return ok({
375
+ blocked: true,
376
+ message: "Refusing to run: confirm=false. This executes real side effects (SMS/email/calls). Ask the user to approve and retry with confirm=true, or use trillet_test_workflow for a simulation.",
377
+ });
378
+ }
379
+ const res = await client.request("POST", `/v1/api/workflows/${workflow_id}/run`, {
380
+ body: { triggerData: trigger_data },
381
+ workspaceId: workspace_id,
382
+ });
383
+ return ok(res);
384
+ }));
385
+ server.registerTool("trillet_list_workflow_runs", {
386
+ title: "List workflow runs",
387
+ description: "List runs of a workflow (newest first) with status, contact, progress, and skip/error reasons. Statuses: running, waiting, queued, completed, failed, skipped (see skipReason - 'max_runs_per_contact' means the per-contact cap silently ate the trigger), filtered, cancelled.",
388
+ inputSchema: {
389
+ workflow_id: z.string(),
390
+ status: z.string().optional(),
391
+ page: z.number().default(1),
392
+ limit: z.number().default(20),
393
+ ...workspaceParam,
394
+ },
395
+ annotations: { readOnlyHint: true, openWorldHint: true },
396
+ }, guard(async ({ workflow_id, status, page, limit, workspace_id }) => {
397
+ const res = await client.request("GET", `/v1/api/workflows/${workflow_id}/runs`, {
398
+ query: { status, page, limit },
399
+ workspaceId: workspace_id,
400
+ });
401
+ const runs = res.runs ?? res.data ?? [];
402
+ return ok({ total: res.total, runs: runs.map(summarizeRun) });
403
+ }));
404
+ server.registerTool("trillet_get_workflow_run", {
405
+ title: "Get workflow run detail",
406
+ description: "Fetch one workflow run with its per-step logs (inputs, outputs, errors, timings), plus parent/child runs for for_each loops. The primary tool for debugging why a workflow run did what it did.",
407
+ inputSchema: { run_id: z.string(), ...workspaceParam },
408
+ annotations: { readOnlyHint: true, openWorldHint: true },
409
+ }, guard(async ({ run_id, workspace_id }) => {
410
+ const res = await client.request("GET", `/v1/api/workflows/runs/${run_id}`, {
411
+ workspaceId: workspace_id,
412
+ });
413
+ return ok(res);
414
+ }));
415
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Single source of truth for the server version, used by the MCP handshake,
3
+ * the startup log, the HTTP user-agent, and the bundle header. Keep in sync
4
+ * with .claude-plugin/plugin.json, mcpb/manifest.json, and package.json when
5
+ * releasing (see README "Maintainers").
6
+ */
7
+ export const VERSION = "0.7.3";