@vellumai/assistant 0.4.18 → 0.4.19

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.
@@ -5,11 +5,13 @@ Operational procedures for inspecting, managing, and debugging the trusted conta
5
5
  ## Prerequisites
6
6
 
7
7
  ```bash
8
- # Read the bearer token
9
- TOKEN=$(cat ~/.vellum/http-token)
10
-
11
8
  # Base URL (adjust if using a non-default port)
12
9
  BASE=http://localhost:7830
10
+
11
+ # Bearer token: if running via the assistant's shell tools, $GATEWAY_AUTH_TOKEN
12
+ # is injected automatically. For manual operator use, mint a token via the CLI
13
+ # or use one from the daemon (e.g. from a recent shell env export).
14
+ TOKEN=$GATEWAY_AUTH_TOKEN
13
15
  ```
14
16
 
15
17
  ## 1. Inspect Trusted Contacts (Members)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.4.18",
3
+ "version": "0.4.19",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -132,9 +132,11 @@ describe("getChannelApprovalPrompt", () => {
132
132
  const result = getChannelApprovalPrompt("conv-1");
133
133
  expect(result).not.toBeNull();
134
134
  expect(result!.promptText).toContain("shell");
135
- expect(result!.actions).toHaveLength(3);
135
+ expect(result!.actions).toHaveLength(5);
136
136
  expect(result!.actions.map((a) => a.id)).toEqual([
137
137
  "approve_once",
138
+ "approve_10m",
139
+ "approve_thread",
138
140
  "approve_always",
139
141
  "reject",
140
142
  ]);
@@ -174,6 +176,8 @@ describe("getChannelApprovalPrompt", () => {
174
176
  expect(result).not.toBeNull();
175
177
  expect(result!.actions.map((a) => a.id)).toEqual([
176
178
  "approve_once",
179
+ "approve_10m",
180
+ "approve_thread",
177
181
  "approve_always",
178
182
  "reject",
179
183
  ]);
@@ -189,6 +193,8 @@ describe("getChannelApprovalPrompt", () => {
189
193
  expect(result).not.toBeNull();
190
194
  expect(result!.actions.map((a) => a.id)).toEqual([
191
195
  "approve_once",
196
+ "approve_10m",
197
+ "approve_thread",
192
198
  "approve_always",
193
199
  "reject",
194
200
  ]);
@@ -79,6 +79,14 @@ mock.module("../runtime/local-actor-identity.js", () => ({
79
79
  }),
80
80
  }));
81
81
 
82
+ mock.module("../runtime/guardian-context-resolver.js", () => ({
83
+ resolveGuardianContext: () => ({
84
+ trustClass: "guardian",
85
+ sourceChannel: "vellum",
86
+ }),
87
+ toGuardianRuntimeContext: (ctx: unknown) => ctx,
88
+ }));
89
+
82
90
  import type { AuthContext } from "../runtime/auth/types.js";
83
91
  import { handleSendMessage } from "../runtime/routes/conversation-routes.js";
84
92
 
@@ -131,6 +131,8 @@ class MockSession {
131
131
  this.guardianContext = ctx;
132
132
  }
133
133
 
134
+ setAuthContext(): void {}
135
+
134
136
  setChannelCapabilities(): void {}
135
137
 
136
138
  setCommandIntent(): void {}
@@ -11,6 +11,13 @@ const toolsManifestPath = resolve(
11
11
  "../config/bundled-skills/messaging/TOOLS.json",
12
12
  );
13
13
  const toolsManifest = JSON.parse(readFileSync(toolsManifestPath, "utf-8"));
14
+ const slackToolsManifestPath = resolve(
15
+ __dirname,
16
+ "../config/bundled-skills/slack/TOOLS.json",
17
+ );
18
+ const slackToolsManifest = JSON.parse(
19
+ readFileSync(slackToolsManifestPath, "utf-8"),
20
+ );
14
21
 
15
22
  describe("Messaging tool contract", () => {
16
23
  const expectedGmailToolNames = [
@@ -70,19 +77,21 @@ describe("Messaging tool contract", () => {
70
77
  });
71
78
 
72
79
  test("TOOLS.json manifest contains all expected slack_* tool names", () => {
73
- const manifestToolNames: string[] = toolsManifest.tools.map(
80
+ const slackToolNames: string[] = slackToolsManifest.tools.map(
74
81
  (t: { name: string }) => t.name,
75
82
  );
76
83
  for (const name of expectedSlackToolNames) {
77
- expect(manifestToolNames).toContain(name);
84
+ expect(slackToolNames).toContain(name);
78
85
  }
79
86
  });
80
87
 
81
- test("TOOLS.json manifest contains at least the expected number of tools", () => {
88
+ test("TOOLS.json manifests contain at least the expected number of tools", () => {
82
89
  const expectedMinimum =
83
90
  expectedGmailToolNames.length +
84
91
  expectedMessagingToolNames.length +
85
92
  expectedSlackToolNames.length;
86
- expect(toolsManifest.tools.length).toBeGreaterThanOrEqual(expectedMinimum);
93
+ const totalTools =
94
+ toolsManifest.tools.length + slackToolsManifest.tools.length;
95
+ expect(totalTools).toBeGreaterThanOrEqual(expectedMinimum);
87
96
  });
88
97
  });
@@ -1,7 +1,12 @@
1
1
  import * as net from "node:net";
2
2
  import { describe, expect, mock, test } from "bun:test";
3
3
 
4
- mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true }));
4
+ const actualEnv = await import("../config/env.js");
5
+ mock.module("../config/env.js", () => ({
6
+ ...actualEnv,
7
+ isHttpAuthDisabled: () => true,
8
+ isMonitoringEnabled: () => false,
9
+ }));
5
10
 
6
11
  const { handleUserMessage } = await import("../daemon/handlers/sessions.js");
7
12
 
@@ -47,6 +52,7 @@ describe("handleUserMessage secret redirect continuation", () => {
47
52
  setAssistantId: () => {},
48
53
  setChannelCapabilities: () => {},
49
54
  setGuardianContext: () => {},
55
+ setAuthContext: () => {},
50
56
  setCommandIntent: () => {},
51
57
  updateClient: () => {},
52
58
  emitActivityState: () => {},
@@ -116,6 +116,7 @@ interface TestSession {
116
116
  setTurnInterfaceContext: (ctx: unknown) => void;
117
117
  setAssistantId: (assistantId: string) => void;
118
118
  setGuardianContext: (ctx: unknown) => void;
119
+ setAuthContext: (ctx: unknown) => void;
119
120
  setCommandIntent: (intent: unknown) => void;
120
121
  updateClient: (
121
122
  sendToClient: (msg: ServerMessage) => void,
@@ -180,6 +181,7 @@ function makeSession(overrides: Partial<TestSession> = {}): TestSession {
180
181
  setTurnInterfaceContext: () => {},
181
182
  setAssistantId: () => {},
182
183
  setGuardianContext: () => {},
184
+ setAuthContext: () => {},
183
185
  setCommandIntent: () => {},
184
186
  updateClient: () => {},
185
187
  emitActivityState: () => {},
@@ -66,6 +66,13 @@ mock.module("../providers/registry.js", () => ({
66
66
  initializeProviders: () => {},
67
67
  }));
68
68
 
69
+ // Mock token service — triggerGatewayReconcile now uses mintDaemonDeliveryToken
70
+ // instead of readHttpToken for the Bearer token.
71
+ let mintedToken: string | null = null;
72
+ mock.module("../runtime/auth/token-service.js", () => ({
73
+ mintDaemonDeliveryToken: () => mintedToken ?? "test-delivery-token",
74
+ }));
75
+
69
76
  import { handleIngressConfig } from "../daemon/handlers/config.js";
70
77
  import type { HandlerContext } from "../daemon/handlers/shared.js";
71
78
  import type {
@@ -121,6 +128,7 @@ describe("Ingress reconcile trigger in handleIngressConfig", () => {
121
128
  beforeEach(() => {
122
129
  rawConfigStore = {};
123
130
  httpTokenValue = null;
131
+ mintedToken = null;
124
132
  reconcileCalls = [];
125
133
  fetchShouldFail = false;
126
134
 
@@ -186,7 +194,7 @@ describe("Ingress reconcile trigger in handleIngressConfig", () => {
186
194
 
187
195
  // ── Token present/missing behavior ──────────────────────────────────────
188
196
 
189
- test("skips reconcile trigger when no HTTP bearer token is available", async () => {
197
+ test("always triggers reconcile even when readHttpToken returns null (uses mintDaemonDeliveryToken)", async () => {
190
198
  httpTokenValue = null;
191
199
 
192
200
  const msg: IngressConfigRequest = {
@@ -206,12 +214,12 @@ describe("Ingress reconcile trigger in handleIngressConfig", () => {
206
214
  const res = sent[0] as { type: string; success: boolean };
207
215
  expect(res.success).toBe(true);
208
216
 
209
- // No reconcile call should have been made
210
- expect(reconcileCalls).toHaveLength(0);
217
+ // Reconcile is always triggered using mintDaemonDeliveryToken
218
+ expect(reconcileCalls).toHaveLength(1);
211
219
  });
212
220
 
213
- test("triggers reconcile when HTTP bearer token is available", async () => {
214
- httpTokenValue = "test-bearer-token";
221
+ test("triggers reconcile with mintDaemonDeliveryToken bearer token", async () => {
222
+ mintedToken = "test-bearer-token";
215
223
 
216
224
  const msg: IngressConfigRequest = {
217
225
  type: "ingress_config",
@@ -144,7 +144,7 @@ describe("vellum mcp list", () => {
144
144
  expect(stdout).toContain("stdio");
145
145
  expect(stdout).toContain("npx -y some-mcp-server");
146
146
  expect(stdout).toContain("low");
147
- });
147
+ }, 15_000);
148
148
 
149
149
  test("--json outputs valid JSON", () => {
150
150
  writeConfig({
@@ -1,7 +1,12 @@
1
1
  import * as net from "node:net";
2
2
  import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
3
3
 
4
- mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true }));
4
+ const actualEnv = await import("../config/env.js");
5
+ mock.module("../config/env.js", () => ({
6
+ ...actualEnv,
7
+ isHttpAuthDisabled: () => true,
8
+ isMonitoringEnabled: () => false,
9
+ }));
5
10
 
6
11
  // ─── Mocks (must be before any imports that depend on them) ─────────────────
7
12
 
@@ -351,7 +356,9 @@ mock.module("../subagent/index.js", () => ({
351
356
 
352
357
  // ── Mock IPC protocol helpers ──────────────────────────────────────────────
353
358
 
359
+ const actualIpcProtocol = await import("../daemon/ipc-protocol.js");
354
360
  mock.module("../daemon/ipc-protocol.js", () => ({
361
+ ...actualIpcProtocol,
355
362
  normalizeThreadType: (t: string) => t ?? "primary",
356
363
  }));
357
364
 
@@ -414,6 +421,7 @@ function createCtx(overrides?: Partial<HandlerContext>): {
414
421
  setAssistantId: noop,
415
422
  setChannelCapabilities: noop,
416
423
  setGuardianContext: noop,
424
+ setAuthContext: noop,
417
425
  setCommandIntent: noop,
418
426
  updateClient: noop,
419
427
  processMessage: async () => {},
@@ -108,6 +108,7 @@ function makeCompletingSession(): Session {
108
108
  setChannelCapabilities: () => {},
109
109
  setAssistantId: () => {},
110
110
  setGuardianContext: () => {},
111
+ setAuthContext: () => {},
111
112
  setCommandIntent: () => {},
112
113
  setTurnChannelContext: () => {},
113
114
  setTurnInterfaceContext: () => {},
@@ -160,6 +161,7 @@ function makeHangingSession(): Session {
160
161
  setChannelCapabilities: () => {},
161
162
  setAssistantId: () => {},
162
163
  setGuardianContext: () => {},
164
+ setAuthContext: () => {},
163
165
  setCommandIntent: () => {},
164
166
  setTurnChannelContext: () => {},
165
167
  setTurnInterfaceContext: () => {},
@@ -236,7 +238,11 @@ function makePendingApprovalSession(
236
238
  },
237
239
  setChannelCapabilities: () => {},
238
240
  setAssistantId: () => {},
239
- setGuardianContext: () => {},
241
+ guardianContext: undefined as unknown,
242
+ setGuardianContext(this: { guardianContext: unknown }, ctx: unknown) {
243
+ this.guardianContext = ctx;
244
+ },
245
+ setAuthContext: () => {},
240
246
  setCommandIntent: () => {},
241
247
  setTurnChannelContext: () => {},
242
248
  setTurnInterfaceContext: () => {},
@@ -290,7 +296,7 @@ describe("POST /v1/messages — queue-if-busy and hub publishing", () => {
290
296
  createBinding({
291
297
  assistantId: "self",
292
298
  channel: "vellum",
293
- guardianExternalUserId: "guardian-vellum",
299
+ guardianExternalUserId: "dev-bypass",
294
300
  guardianDeliveryChatId: "vellum",
295
301
  guardianPrincipalId: "test-principal-id",
296
302
  });
@@ -37,6 +37,10 @@ mock.module("../util/platform.js", () => ({
37
37
  readHttpToken: () => "runtime-token",
38
38
  }));
39
39
 
40
+ mock.module("../runtime/auth/token-service.js", () => ({
41
+ mintDaemonDeliveryToken: () => "runtime-token",
42
+ }));
43
+
40
44
  mock.module("../config/loader.js", () => ({
41
45
  loadConfig: () => configState,
42
46
  }));
@@ -72,6 +72,7 @@ const {
72
72
  ensurePromptFiles,
73
73
  stripCommentLines,
74
74
  buildExternalCommsIdentitySection,
75
+ buildPhoneCallsRoutingSection,
75
76
  } = await import("../config/system-prompt.js");
76
77
 
77
78
  /** Strip the Configuration and Skills sections so base-prompt tests stay focused. */
@@ -224,6 +225,26 @@ describe("buildSystemPrompt", () => {
224
225
  expect(section).toContain("Occasional variations are acceptable");
225
226
  });
226
227
 
228
+ test("includes phone calls routing section", () => {
229
+ const result = buildSystemPrompt();
230
+ expect(result).toContain("## Routing: Phone Calls");
231
+ expect(result).toContain('skill: "phone-calls"');
232
+ });
233
+
234
+ test("buildPhoneCallsRoutingSection returns section with expected content", () => {
235
+ const section = buildPhoneCallsRoutingSection();
236
+ expect(section).toContain("## Routing: Phone Calls");
237
+ expect(section).toContain("Trigger phrases");
238
+ expect(section).toContain("Exclusivity rules");
239
+ expect(section).toContain("phone-calls");
240
+ expect(section).toContain("Do NOT improvise Twilio setup instructions");
241
+ });
242
+
243
+ test("phone calls routing section excluded from low tier", () => {
244
+ const result = buildSystemPrompt("low");
245
+ expect(result).not.toContain("## Routing: Phone Calls");
246
+ });
247
+
227
248
  test("includes memory persistence section in high tier", () => {
228
249
  const result = buildSystemPrompt("high");
229
250
  expect(result).toContain("## Memory Persistence");
@@ -78,7 +78,9 @@ mock.module("../tools/network/script-proxy/index.js", () => ({
78
78
  }));
79
79
 
80
80
  mock.module("../util/platform.js", () => ({
81
+ getRootDir: () => "/tmp",
81
82
  getDataDir: () => "/tmp",
83
+ getRootDir: () => "/tmp",
82
84
  getSocketPath: () => "/tmp/vellum.sock",
83
85
  }));
84
86
 
package/src/cli/mcp.ts CHANGED
@@ -15,7 +15,7 @@ const log = getCliLogger('cli');
15
15
  export const HEALTH_CHECK_TIMEOUT_MS = 10_000;
16
16
 
17
17
  export async function checkServerHealth(serverId: string, config: McpServerConfig, timeoutMs = HEALTH_CHECK_TIMEOUT_MS): Promise<string> {
18
- const client = new McpClient(serverId, { quiet: true });
18
+ const client = new McpClient(serverId);
19
19
  try {
20
20
  await Promise.race([
21
21
  client.connect(config.transport),
@@ -73,40 +73,93 @@ export function registerMcpCommand(program: Command): void {
73
73
 
74
74
  log.info(`${entries.length} MCP server(s) configured:\n`);
75
75
 
76
- let didHealthCheck = false;
77
- for (const [id, cfg] of entries) {
78
- if (!cfg || typeof cfg !== 'object') {
79
- log.info(` ${id} (invalid config — skipped)\n`);
80
- continue;
81
- }
82
- const enabled = cfg.enabled !== false;
83
- const transport = cfg.transport;
84
- const risk = cfg.defaultRiskLevel ?? 'high';
76
+ const isTTY = process.stdout.isTTY;
85
77
 
86
- let status: string;
87
- if (!enabled) {
88
- status = '✗ disabled';
89
- } else {
90
- status = await checkServerHealth(id, cfg);
91
- didHealthCheck = true;
78
+ if (isTTY) {
79
+ // TTY path: print placeholders, run health checks in parallel, update in-place with ANSI codes
80
+ let lineCount = 0;
81
+ const healthChecks: { id: string; cfg: McpServerConfig; statusLine: number }[] = [];
82
+
83
+ for (const [id, cfg] of entries) {
84
+ if (!cfg || typeof cfg !== 'object') {
85
+ log.info(` ${id} (invalid config — skipped)\n`);
86
+ lineCount += 2;
87
+ continue;
88
+ }
89
+ const enabled = cfg.enabled !== false;
90
+ const transport = cfg.transport;
91
+ const risk = cfg.defaultRiskLevel ?? 'high';
92
+ const statusText = !enabled ? '✗ disabled' : '⏳ Checking...';
93
+
94
+ log.info(` ${id}`);
95
+ lineCount++;
96
+ const statusLine = lineCount;
97
+ log.info(` Status: ${statusText}`);
98
+ lineCount++;
99
+ log.info(` Transport: ${transport?.type ?? 'unknown'}`);
100
+ lineCount++;
101
+ if (transport?.type === 'stdio') {
102
+ log.info(` Command: ${transport.command} ${(transport.args ?? []).join(' ')}`);
103
+ lineCount++;
104
+ } else if (transport && 'url' in transport) {
105
+ log.info(` URL: ${transport.url}`);
106
+ lineCount++;
107
+ }
108
+ log.info(` Risk: ${risk}`);
109
+ lineCount++;
110
+ if (cfg.allowedTools) { log.info(` Allowed: ${cfg.allowedTools.join(', ')}`); lineCount++; }
111
+ if (cfg.blockedTools) { log.info(` Blocked: ${cfg.blockedTools.join(', ')}`); lineCount++; }
112
+ log.info('');
113
+ lineCount++;
114
+
115
+ if (enabled) {
116
+ healthChecks.push({ id, cfg, statusLine });
117
+ }
92
118
  }
93
119
 
94
- log.info(` ${id}`);
95
- log.info(` Status: ${status}`);
96
- log.info(` Transport: ${transport?.type ?? 'unknown'}`);
97
- if (transport?.type === 'stdio') {
98
- log.info(` Command: ${transport.command} ${(transport.args ?? []).join(' ')}`);
99
- } else if (transport && 'url' in transport) {
100
- log.info(` URL: ${transport.url}`);
120
+ if (healthChecks.length === 0) return;
121
+
122
+ // Run health checks in parallel, update status lines in-place with ANSI codes
123
+ await Promise.all(healthChecks.map(async ({ id, cfg, statusLine }) => {
124
+ const health = await checkServerHealth(id, cfg);
125
+ const up = lineCount - statusLine;
126
+ process.stdout.write(`\x1b[${up}A\r\x1b[2K Status: ${health}\x1b[${up}B\r`);
127
+ }));
128
+ } else {
129
+ // Non-TTY path: run health checks sequentially, print final status directly (no ANSI codes)
130
+ for (const [id, cfg] of entries) {
131
+ if (!cfg || typeof cfg !== 'object') {
132
+ log.info(` ${id} (invalid config — skipped)\n`);
133
+ continue;
134
+ }
135
+ const enabled = cfg.enabled !== false;
136
+ const transport = cfg.transport;
137
+ const risk = cfg.defaultRiskLevel ?? 'high';
138
+
139
+ let statusText: string;
140
+ if (!enabled) {
141
+ statusText = '✗ disabled';
142
+ } else {
143
+ statusText = await checkServerHealth(id, cfg);
144
+ }
145
+
146
+ log.info(` ${id}`);
147
+ log.info(` Status: ${statusText}`);
148
+ log.info(` Transport: ${transport?.type ?? 'unknown'}`);
149
+ if (transport?.type === 'stdio') {
150
+ log.info(` Command: ${transport.command} ${(transport.args ?? []).join(' ')}`);
151
+ } else if (transport && 'url' in transport) {
152
+ log.info(` URL: ${transport.url}`);
153
+ }
154
+ log.info(` Risk: ${risk}`);
155
+ if (cfg.allowedTools) { log.info(` Allowed: ${cfg.allowedTools.join(', ')}`); }
156
+ if (cfg.blockedTools) { log.info(` Blocked: ${cfg.blockedTools.join(', ')}`); }
157
+ log.info('');
101
158
  }
102
- log.info(` Risk: ${risk}`);
103
- if (cfg.allowedTools) log.info(` Allowed: ${cfg.allowedTools.join(', ')}`);
104
- if (cfg.blockedTools) log.info(` Blocked: ${cfg.blockedTools.join(', ')}`);
105
- log.info('');
106
159
  }
107
160
 
108
161
  // Health checks may leave MCP transports alive — force exit
109
- if (didHealthCheck) process.exit(0);
162
+ process.exit(0);
110
163
  });
111
164
 
112
165
  mcp
@@ -11,7 +11,7 @@ You are helping your user set up guardian verification for a messaging channel (
11
11
 
12
12
  - Use the injected `INTERNAL_GATEWAY_BASE_URL` for gateway API calls.
13
13
  - Never call the daemon runtime port directly; always call the gateway URL.
14
- - The bearer token is stored at `~/.vellum/http-token`. Read it with: `TOKEN=$(cat ~/.vellum/http-token)`.
14
+ - The bearer token is available as the `$GATEWAY_AUTH_TOKEN` environment variable.
15
15
  - Run shell commands for this skill with `host_bash` (not sandbox `bash`) so host auth/token and gateway routing are reliable.
16
16
  - Keep narration minimal: execute required calls first, then provide a concise status update. Do not narrate internal install/check/load chatter unless something fails.
17
17
 
@@ -39,10 +39,9 @@ Based on the chosen channel, ask for the required destination:
39
39
  Execute the outbound start request:
40
40
 
41
41
  ```bash
42
- TOKEN=$(cat ~/.vellum/http-token)
43
42
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/outbound/start" \
44
43
  -H "Content-Type: application/json" \
45
- -H "Authorization: Bearer $TOKEN" \
44
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
46
45
  -d '{"channel": "<channel>", "destination": "<destination>"}'
47
46
  ```
48
47
 
@@ -77,10 +76,9 @@ Handle each error code:
77
76
  If the user says they did not receive the code or asks to resend:
78
77
 
79
78
  ```bash
80
- TOKEN=$(cat ~/.vellum/http-token)
81
79
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/outbound/resend" \
82
80
  -H "Content-Type: application/json" \
83
- -H "Authorization: Bearer $TOKEN" \
81
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
84
82
  -d '{"channel": "<channel>"}'
85
83
  ```
86
84
 
@@ -107,10 +105,9 @@ Handle each error code from the resend endpoint:
107
105
  If the user wants to cancel the verification:
108
106
 
109
107
  ```bash
110
- TOKEN=$(cat ~/.vellum/http-token)
111
108
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/outbound/cancel" \
112
109
  -H "Content-Type: application/json" \
113
- -H "Authorization: Bearer $TOKEN" \
110
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
114
111
  -d '{"channel": "<channel>"}'
115
112
  ```
116
113
 
@@ -126,9 +123,8 @@ For **voice** verification only: after telling the user their code and instructi
126
123
  2. Check the binding status:
127
124
 
128
125
  ```bash
129
- TOKEN=$(cat ~/.vellum/http-token)
130
126
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=voice" \
131
- -H "Authorization: Bearer $TOKEN"
127
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
132
128
  ```
133
129
 
134
130
  3. If the response shows `bound: true`: immediately send a proactive success message in the current chat — "Voice verification complete! Your phone number is now the trusted guardian." Stop polling.
@@ -153,9 +149,8 @@ When in a **rebind flow** (i.e., the `start_outbound` request included `"rebind"
153
149
  After the user reports entering the code, verify the binding was created:
154
150
 
155
151
  ```bash
156
- TOKEN=$(cat ~/.vellum/http-token)
157
152
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=<channel>" \
158
- -H "Authorization: Bearer $TOKEN"
153
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
159
154
  ```
160
155
 
161
156
  If the response shows the guardian is bound, confirm success: "Guardian verified! Your [channel] identity is now the trusted guardian."
@@ -49,9 +49,8 @@ The user's assistant gets its own personal phone number through Twilio. All impl
49
49
  Check whether Twilio credentials, phone number, and public ingress are already configured:
50
50
 
51
51
  ```bash
52
- TOKEN=$(cat ~/.vellum/http-token)
53
52
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/config" \
54
- -H "Authorization: Bearer $TOKEN"
53
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
55
54
  ```
56
55
 
57
56
  ```bash
@@ -12,9 +12,8 @@ You are helping your user set up SMS messaging. This skill orchestrates Twilio s
12
12
  First, check the current SMS channel readiness state via the gateway:
13
13
 
14
14
  ```bash
15
- TOKEN=$(cat ~/.vellum/http-token)
16
15
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/config" \
17
- -H "Authorization: Bearer $TOKEN"
16
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
18
17
  ```
19
18
 
20
19
  Inspect the response for `hasCredentials` and `phoneNumber`.
@@ -35,9 +34,8 @@ Tell the user: _"SMS needs Twilio configured first. I've loaded the Twilio setup
35
34
  After twilio-setup completes, re-check readiness by calling the config endpoint again:
36
35
 
37
36
  ```bash
38
- TOKEN=$(cat ~/.vellum/http-token)
39
37
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/config" \
40
- -H "Authorization: Bearer $TOKEN"
38
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
41
39
  ```
42
40
 
43
41
  If baseline is still not ready, report the specific failures and ask the user to address them before continuing.
@@ -47,9 +45,8 @@ If baseline is still not ready, report the specific failures and ask the user to
47
45
  Once baseline is ready, check SMS compliance status including remote (Twilio API) checks:
48
46
 
49
47
  ```bash
50
- TOKEN=$(cat ~/.vellum/http-token)
51
48
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/sms/compliance" \
52
- -H "Authorization: Bearer $TOKEN"
49
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
53
50
  ```
54
51
 
55
52
  Examine the compliance results:
@@ -90,9 +87,8 @@ The `tollfreePhoneNumberSid` is returned by the compliance status response in th
90
87
  **Step 3c: Submit verification:**
91
88
 
92
89
  ```bash
93
- TOKEN=$(cat ~/.vellum/http-token)
94
90
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/sms/compliance/tollfree" \
95
- -H "Authorization: Bearer $TOKEN" \
91
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
96
92
  -H "Content-Type: application/json" \
97
93
  -d '{
98
94
  "tollfreePhoneNumberSid": "<compliance.tollfreePhoneNumberSid from Step 3a>",
@@ -118,9 +114,8 @@ The endpoint validates all fields before submitting to Twilio and returns clear
118
114
  **Step 3d: Update a rejected verification** (if `editAllowed` is true):
119
115
 
120
116
  ```bash
121
- TOKEN=$(cat ~/.vellum/http-token)
122
117
  curl -s -X PATCH "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/sms/compliance/tollfree/<verificationSid>" \
123
- -H "Authorization: Bearer $TOKEN" \
118
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
124
119
  -H "Content-Type: application/json" \
125
120
  -d '{
126
121
  "businessName": "updated value",
@@ -133,9 +128,8 @@ Only include fields that need to change. The endpoint checks edit eligibility an
133
128
  **Step 3e: Delete and resubmit** (if editing is not allowed):
134
129
 
135
130
  ```bash
136
- TOKEN=$(cat ~/.vellum/http-token)
137
131
  curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/sms/compliance/tollfree/<verificationSid>" \
138
- -H "Authorization: Bearer $TOKEN"
132
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
139
133
  ```
140
134
 
141
135
  After deletion, return to Step 3b to collect information and resubmit. Warn the user that deleting resets their position in the review queue.
@@ -183,9 +177,8 @@ Tell the user: _"Let's send a test SMS to verify everything works. What phone nu
183
177
  After the user provides a number, send a test message via the gateway:
184
178
 
185
179
  ```bash
186
- TOKEN=$(cat ~/.vellum/http-token)
187
180
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/sms/test" \
188
- -H "Authorization: Bearer $TOKEN" \
181
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
189
182
  -H "Content-Type: application/json" \
190
183
  -d '{"phoneNumber":"<recipient phone number>","text":"Test SMS from your Vellum assistant."}'
191
184
  ```
@@ -200,9 +193,8 @@ Report the result honestly:
200
193
  If the test fails or the user reports SMS issues, run the SMS doctor:
201
194
 
202
195
  ```bash
203
- TOKEN=$(cat ~/.vellum/http-token)
204
196
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/sms/doctor" \
205
- -H "Authorization: Bearer $TOKEN"
197
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
206
198
  ```
207
199
 
208
200
  This runs a comprehensive health diagnostic, checking channel readiness, compliance/toll-free verification status, and the last test result. Report the diagnostics and actionable items to the user.
@@ -39,7 +39,7 @@ After the token is collected, call the composite setup endpoint which validates
39
39
 
40
40
  ```bash
41
41
  curl -sf -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/setup" \
42
- -H "Authorization: Bearer $(cat ~/.vellum/http-token)" \
42
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
43
43
  -H "Content-Type: application/json" \
44
44
  -d '{}'
45
45
  ```
@@ -101,7 +101,7 @@ Before reporting success, confirm the guardian binding was actually created. Che
101
101
 
102
102
  ```bash
103
103
  curl -sf "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=telegram" \
104
- -H "Authorization: Bearer $(cat ~/.vellum/http-token)"
104
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
105
105
  ```
106
106
 
107
107
  If the binding is absent and the user said they completed the verification:
@@ -120,7 +120,7 @@ Summarize what was done:
120
120
  - Guardian identity: {verified | not configured}
121
121
  - Guardian verification status: {verified via outbound flow | skipped}
122
122
  - Routing configuration validated
123
- - To re-check guardian status later, use: `curl -sf "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=telegram" -H "Authorization: Bearer $(cat ~/.vellum/http-token)"`
123
+ - To re-check guardian status later, use: `curl -sf "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=telegram" -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"`
124
124
 
125
125
  The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. In single-assistant mode, routing is automatically configured — no manual environment variable configuration or webhook registration is needed. If the webhook secret changes later, the gateway's credential watcher will automatically re-register the webhook. If the ingress URL changes (e.g., tunnel restart), the assistant daemon triggers an immediate internal reconcile so the webhook re-registers automatically without a gateway restart.
126
126
 
@@ -11,7 +11,7 @@ You are helping your user manage trusted contacts and invite links for the Vellu
11
11
 
12
12
  - Use the injected `INTERNAL_GATEWAY_BASE_URL` for gateway API calls.
13
13
  - Use gateway control-plane routes only: this skill calls `/v1/ingress/*` and `/v1/integrations/telegram/config` on the gateway, never the daemon runtime port directly.
14
- - The bearer token is stored at `~/.vellum/http-token`. Read it with: `TOKEN=$(cat ~/.vellum/http-token)`.
14
+ - The bearer token is available as the `$GATEWAY_AUTH_TOKEN` environment variable.
15
15
 
16
16
  ## Concepts
17
17
 
@@ -29,9 +29,8 @@ You are helping your user manage trusted contacts and invite links for the Vellu
29
29
  Use this to show the user who currently has access, or to look up a specific contact.
30
30
 
31
31
  ```bash
32
- TOKEN=$(cat ~/.vellum/http-token)
33
32
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members" \
34
- -H "Authorization: Bearer $TOKEN"
33
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
35
34
  ```
36
35
 
37
36
  Optional query parameters for filtering:
@@ -42,7 +41,7 @@ Optional query parameters for filtering:
42
41
  Example with filters:
43
42
  ```bash
44
43
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members?sourceChannel=telegram&status=active" \
45
- -H "Authorization: Bearer $TOKEN"
44
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
46
45
  ```
47
46
 
48
47
  The response contains `{ ok: true, members: [...] }` where each member has:
@@ -65,10 +64,9 @@ Use this when the user wants to grant someone access to message the assistant. *
65
64
  Ask the user: *"I'll add [name/identifier] on [channel] as an allowed contact. Should I proceed?"*
66
65
 
67
66
  ```bash
68
- TOKEN=$(cat ~/.vellum/http-token)
69
67
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members" \
70
68
  -H "Content-Type: application/json" \
71
- -H "Authorization: Bearer $TOKEN" \
69
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
72
70
  -d '{
73
71
  "sourceChannel": "<channel>",
74
72
  "externalUserId": "<user_id>",
@@ -97,9 +95,8 @@ Ask the user: *"I'll revoke access for [name/identifier]. They will no longer be
97
95
  First, list members to find the member's `id`, then revoke:
98
96
 
99
97
  ```bash
100
- TOKEN=$(cat ~/.vellum/http-token)
101
98
  curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members/<member_id>" \
102
- -H "Authorization: Bearer $TOKEN" \
99
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
103
100
  -H "Content-Type: application/json" \
104
101
  -d '{"reason": "<optional reason>"}'
105
102
  ```
@@ -113,10 +110,9 @@ Use this when the user wants to explicitly block someone. Blocking is stronger t
113
110
  Ask the user: *"I'll block [name/identifier]. They will be permanently denied from messaging the assistant. Should I proceed?"*
114
111
 
115
112
  ```bash
116
- TOKEN=$(cat ~/.vellum/http-token)
117
113
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members/<member_id>/block" \
118
114
  -H "Content-Type: application/json" \
119
- -H "Authorization: Bearer $TOKEN" \
115
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
120
116
  -d '{"reason": "<optional reason>"}'
121
117
  ```
122
118
 
@@ -127,11 +123,9 @@ Use this when the guardian wants to invite someone to message the assistant on T
127
123
  **Important**: The shell snippet below emits a `<vellum-sensitive-output>` directive containing the raw invite token. The tool executor automatically strips this directive and replaces the raw token with a placeholder so the LLM never sees it. The placeholder is resolved back to the real token in the final assistant reply.
128
124
 
129
125
  ```bash
130
- TOKEN=$(cat ~/.vellum/http-token)
131
-
132
126
  INVITE_JSON=$(curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
133
127
  -H "Content-Type: application/json" \
134
- -H "Authorization: Bearer $TOKEN" \
128
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
135
129
  -d '{
136
130
  "sourceChannel": "telegram",
137
131
  "maxUses": 1,
@@ -160,7 +154,7 @@ fi
160
154
  # Prefer backend-provided canonical link when available.
161
155
  if [ -z "$INVITE_URL" ]; then
162
156
  BOT_CONFIG_JSON=$(curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/config" \
163
- -H "Authorization: Bearer $TOKEN")
157
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN")
164
158
  BOT_USERNAME=$(printf '%s' "$BOT_CONFIG_JSON" | tr -d '\n' | sed -n 's/.*"botUsername"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
165
159
  if [ -z "$BOT_USERNAME" ]; then
166
160
  echo "error:no_share_url_or_bot_username"
@@ -201,9 +195,8 @@ If the Telegram bot username is not available (integration not set up), tell the
201
195
  Use this to show the guardian their active (and optionally all) invite links.
202
196
 
203
197
  ```bash
204
- TOKEN=$(cat ~/.vellum/http-token)
205
198
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites?sourceChannel=telegram" \
206
- -H "Authorization: Bearer $TOKEN"
199
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
207
200
  ```
208
201
 
209
202
  Optional query parameters:
@@ -232,9 +225,8 @@ Ask the user: *"I'll revoke the invite link [note or ID]. It will no longer be u
232
225
  First, list invites to find the invite's `id`, then revoke:
233
226
 
234
227
  ```bash
235
- TOKEN=$(cat ~/.vellum/http-token)
236
228
  curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites/<invite_id>" \
237
- -H "Authorization: Bearer $TOKEN"
229
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
238
230
  ```
239
231
 
240
232
  Replace `<invite_id>` with the invite's `id` from the list response.
@@ -246,11 +238,9 @@ Use this when the guardian wants to authorize a specific phone number to call th
246
238
  **Important**: The response includes a `voiceCode` field that is only returned at creation time and cannot be retrieved later. Extract and present it clearly.
247
239
 
248
240
  ```bash
249
- TOKEN=$(cat ~/.vellum/http-token)
250
-
251
241
  INVITE_JSON=$(curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
252
242
  -H "Content-Type: application/json" \
253
- -H "Authorization: Bearer $TOKEN" \
243
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
254
244
  -d '{
255
245
  "sourceChannel": "voice",
256
246
  "expectedExternalUserId": "<phone_number_E164>",
@@ -303,9 +293,8 @@ If the user provides a phone number without the `+` country code prefix, ask the
303
293
  Use this to show the guardian their active voice invites.
304
294
 
305
295
  ```bash
306
- TOKEN=$(cat ~/.vellum/http-token)
307
296
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites?sourceChannel=voice" \
308
- -H "Authorization: Bearer $TOKEN"
297
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
309
298
  ```
310
299
 
311
300
  Optional query parameters:
@@ -327,9 +316,8 @@ Ask the user: *"I'll revoke the voice invite for [phone number or note]. The cod
327
316
  First, list voice invites to find the invite's `id`, then revoke:
328
317
 
329
318
  ```bash
330
- TOKEN=$(cat ~/.vellum/http-token)
331
319
  curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites/<invite_id>" \
332
- -H "Authorization: Bearer $TOKEN"
320
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
333
321
  ```
334
322
 
335
323
  Replace `<invite_id>` with the invite's `id` from the list response. The same revoke endpoint is used for both Telegram and voice invites.
@@ -11,17 +11,16 @@ You are helping your user configure Twilio for voice calls and SMS messaging. Tw
11
11
  ## Quick Start
12
12
 
13
13
  ```bash
14
- TOKEN=$(cat ~/.vellum/http-token)
15
14
  # 1. Check current status
16
15
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/config" \
17
- -H "Authorization: Bearer $TOKEN"
16
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
18
17
  # 2. Store credentials (after collecting via credential_store prompt)
19
18
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/credentials" \
20
- -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
19
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" -H "Content-Type: application/json" \
21
20
  -d '{"accountSid":"ACxxx","authToken":"xxx"}'
22
21
  # 3. Provision or assign a number
23
22
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/numbers/provision" \
24
- -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
23
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" -H "Content-Type: application/json" \
25
24
  -d '{"country":"US","areaCode":"415"}'
26
25
  ```
27
26
 
@@ -67,9 +66,8 @@ All HTTP examples below include the optional `assistantId` query parameter in as
67
66
  First, check whether Twilio is already configured:
68
67
 
69
68
  ```bash
70
- TOKEN=$(cat ~/.vellum/http-token)
71
69
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/config" \
72
- -H "Authorization: Bearer $TOKEN"
70
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
73
71
  ```
74
72
 
75
73
  The response includes:
@@ -96,9 +94,8 @@ If credentials are not yet stored, guide the user through Twilio account setup:
96
94
  After both credentials are collected, retrieve them from secure storage and send them to the gateway:
97
95
 
98
96
  ```bash
99
- TOKEN=$(cat ~/.vellum/http-token)
100
97
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/credentials" \
101
- -H "Authorization: Bearer $TOKEN" \
98
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
102
99
  -H "Content-Type: application/json" \
103
100
  -d '{"accountSid":"<value from credential_store for twilio/account_sid>","authToken":"<value from credential_store for twilio/auth_token>"}'
104
101
  ```
@@ -116,9 +113,8 @@ The assistant needs a phone number to make calls and send SMS. There are two pat
116
113
  If the user wants to buy a new number through Twilio:
117
114
 
118
115
  ```bash
119
- TOKEN=$(cat ~/.vellum/http-token)
120
116
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/numbers/provision" \
121
- -H "Authorization: Bearer $TOKEN" \
117
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
122
118
  -H "Content-Type: application/json" \
123
119
  -d '{"country":"US","areaCode":"415"}'
124
120
  ```
@@ -143,9 +139,8 @@ If ingress is not yet configured, webhook setup is skipped gracefully — the nu
143
139
  If the user already has a Twilio phone number, first list available numbers:
144
140
 
145
141
  ```bash
146
- TOKEN=$(cat ~/.vellum/http-token)
147
142
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/numbers" \
148
- -H "Authorization: Bearer $TOKEN"
143
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
149
144
  ```
150
145
 
151
146
  The response includes a `numbers` array with each number's `phoneNumber`, `friendlyName`, and `capabilities` (voice, SMS). Present these to the user and let them choose.
@@ -153,9 +148,8 @@ The response includes a `numbers` array with each number's `phoneNumber`, `frien
153
148
  Then assign the chosen number:
154
149
 
155
150
  ```bash
156
- TOKEN=$(cat ~/.vellum/http-token)
157
151
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/numbers/assign" \
158
- -H "Authorization: Bearer $TOKEN" \
152
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
159
153
  -H "Content-Type: application/json" \
160
154
  -d '{"phoneNumber":"+14155551234"}'
161
155
  ```
@@ -173,9 +167,8 @@ credential_store action=store service=twilio field=phone_number value=+141555512
173
167
  Then assign it through the gateway:
174
168
 
175
169
  ```bash
176
- TOKEN=$(cat ~/.vellum/http-token)
177
170
  curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/numbers/assign" \
178
- -H "Authorization: Bearer $TOKEN" \
171
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
179
172
  -H "Content-Type: application/json" \
180
173
  -d '{"phoneNumber":"+14155551234"}'
181
174
  ```
@@ -211,9 +204,8 @@ Webhook URLs are automatically configured on the Twilio phone number when provis
211
204
  After configuration, verify by checking the config endpoint again.
212
205
 
213
206
  ```bash
214
- TOKEN=$(cat ~/.vellum/http-token)
215
207
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/config" \
216
- -H "Authorization: Bearer $TOKEN"
208
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
217
209
  ```
218
210
 
219
211
  Confirm:
@@ -251,13 +243,12 @@ After the guardian-verify-setup skill completes verification for a channel, load
251
243
  To re-check guardian status later, query the channel(s) that were verified:
252
244
 
253
245
  ```bash
254
- TOKEN=$(cat ~/.vellum/http-token)
255
246
  # Check SMS guardian status
256
247
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=sms" \
257
- -H "Authorization: Bearer $TOKEN"
248
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
258
249
  # Check voice guardian status
259
250
  curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=voice" \
260
- -H "Authorization: Bearer $TOKEN"
251
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
261
252
  ```
262
253
 
263
254
  Check the status for whichever channel(s) the user actually verified (SMS, voice, or both). Report the guardian verification result per channel: **"Guardian identity — SMS: {verified | not configured}, Voice: {verified | not configured}."**
@@ -280,9 +271,8 @@ SMS is available automatically once Twilio is configured — no additional featu
280
271
  If the user wants to disconnect Twilio:
281
272
 
282
273
  ```bash
283
- TOKEN=$(cat ~/.vellum/http-token)
284
274
  curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/twilio/credentials" \
285
- -H "Authorization: Bearer $TOKEN"
275
+ -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN"
286
276
  ```
287
277
 
288
278
  This removes the stored Account SID and Auth Token. Phone number assignments are preserved. Voice calls and SMS will stop working until credentials are reconfigured.
@@ -145,6 +145,7 @@ export function buildSystemPrompt(tier: ResponseTier = 'high'): string {
145
145
  parts.push(buildAttachmentSection());
146
146
  parts.push(buildInChatConfigurationSection());
147
147
  parts.push(buildVoiceSetupRoutingSection());
148
+ parts.push(buildPhoneCallsRoutingSection());
148
149
  parts.push(buildChannelCommandIntentSection());
149
150
  }
150
151
 
@@ -373,6 +374,37 @@ export function buildVoiceSetupRoutingSection(): string {
373
374
  ].join('\n');
374
375
  }
375
376
 
377
+ export function buildPhoneCallsRoutingSection(): string {
378
+ return [
379
+ '## Routing: Phone Calls',
380
+ '',
381
+ 'When the user asks to set up phone calling, place a call, configure Twilio for voice, or anything related to outbound/inbound phone calls, load the **Phone Calls** skill.',
382
+ '',
383
+ '### Trigger phrases',
384
+ '- "Set up phone calling" / "enable calls"',
385
+ '- "Make a call to..." / "call [number/business]"',
386
+ '- "Configure Twilio" (in context of voice calls, not SMS)',
387
+ '- "Can you make phone calls?"',
388
+ '- "Set up my phone number" (for calling, not SMS)',
389
+ '',
390
+ '### What it does',
391
+ 'The skill handles the full phone calling lifecycle:',
392
+ '1. Twilio credential setup (delegates to twilio-setup skill)',
393
+ '2. Public ingress configuration (delegates to public-ingress skill)',
394
+ '3. Enabling the calls feature',
395
+ '4. Placing outbound calls and receiving inbound calls',
396
+ '5. Voice quality configuration (standard Twilio TTS or ElevenLabs)',
397
+ '',
398
+ 'Load with: `skill_load` using `skill: "phone-calls"`',
399
+ '',
400
+ '### Exclusivity rules',
401
+ '- Do NOT improvise Twilio setup instructions from general knowledge — always load the skill first.',
402
+ '- Do NOT confuse with voice-setup (local PTT/wake word/microphone) or guardian-verify-setup (channel verification).',
403
+ '- If the user says "voice" in the context of phone calls or Twilio, load phone-calls, not voice-setup.',
404
+ '- For guardian voice verification specifically, load guardian-verify-setup instead.',
405
+ ].join('\n');
406
+ }
407
+
376
408
  function buildToolPermissionSection(): string {
377
409
  return [
378
410
  '## Tool Permissions',
package/src/mcp/client.ts CHANGED
@@ -30,15 +30,13 @@ export class McpClient {
30
30
  private transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null = null;
31
31
  private connected = false;
32
32
  private oauthProvider: McpOAuthProvider | null = null;
33
- private quiet: boolean;
34
33
 
35
34
  get isConnected(): boolean {
36
35
  return this.connected;
37
36
  }
38
37
 
39
- constructor(serverId: string, opts?: { quiet?: boolean }) {
38
+ constructor(serverId: string) {
40
39
  this.serverId = serverId;
41
- this.quiet = opts?.quiet ?? false;
42
40
  this.client = new Client({
43
41
  name: 'vellum-assistant',
44
42
  version: '1.0.0',
@@ -61,7 +59,7 @@ export class McpClient {
61
59
  }
62
60
  }
63
61
 
64
- if (!this.quiet) console.log(`[MCP] Connecting to server "${this.serverId}"...`);
62
+ log.info({ serverId: this.serverId }, 'Connecting to MCP server');
65
63
  this.transport = this.createTransport(transportConfig);
66
64
 
67
65
  try {
@@ -82,13 +80,11 @@ export class McpClient {
82
80
  if (isAuthError) {
83
81
  // Auth-related — user can run `vellum mcp auth <name>` to authenticate.
84
82
  log.info({ serverId: this.serverId, err }, 'MCP server requires authentication');
85
- if (!this.quiet) console.log(`[MCP] Server "${this.serverId}" requires authentication. Run "vellum mcp auth ${this.serverId}" to authenticate.`);
86
83
  return;
87
84
  }
88
85
 
89
86
  // Non-auth error (DNS, TLS, timeout, etc.) — log and re-throw
90
87
  log.error({ serverId: this.serverId, err }, 'MCP server connection failed');
91
- if (!this.quiet) console.error(`[MCP] Server "${this.serverId}" connection failed: ${err instanceof Error ? err.message : err}`);
92
88
  throw err;
93
89
  }
94
90
 
@@ -96,7 +92,6 @@ export class McpClient {
96
92
  }
97
93
 
98
94
  this.connected = true;
99
- if (!this.quiet) console.log(`[MCP] Server "${this.serverId}" connected successfully`);
100
95
  log.info({ serverId: this.serverId }, 'MCP client connected');
101
96
  }
102
97
 
@@ -148,11 +148,19 @@ export function getSecureKey(account: string): string | undefined {
148
148
  * Returns `true` on success, `false` on failure.
149
149
  */
150
150
  export function setSecureKey(account: string, value: string): boolean {
151
- return withKeychainFallback(
151
+ const result = withKeychainFallback(
152
152
  () => keychain.setKey(account, value),
153
153
  () => encryptedStore.setKey(account, value),
154
154
  false,
155
155
  );
156
+ // When writing to the encrypted store after a keychain downgrade, clean up
157
+ // any stale keychain entry so the gateway's credential-reader (which tries
158
+ // keychain first) does not read an outdated value.
159
+ if (result && downgradedFromKeychain && getBackend() === "encrypted") {
160
+ keychainMissCache.delete(account);
161
+ try { keychain.deleteKey(account); } catch { /* best-effort */ }
162
+ }
163
+ return result;
156
164
  }
157
165
 
158
166
  /**
@@ -278,7 +286,15 @@ export async function setSecureKeyAsync(
278
286
  value: string,
279
287
  ): Promise<boolean> {
280
288
  const backend = await getBackendAsync();
281
- if (backend === "encrypted") return encryptedStore.setKey(account, value);
289
+ if (backend === "encrypted") {
290
+ const result = encryptedStore.setKey(account, value);
291
+ // Clean up stale keychain entry (mirrors setSecureKey logic).
292
+ if (result && downgradedFromKeychain) {
293
+ keychainMissCache.delete(account);
294
+ try { await keychain.deleteKeyAsync(account); } catch { /* best-effort */ }
295
+ }
296
+ return result;
297
+ }
282
298
  if (backend !== "keychain") return false;
283
299
 
284
300
  const result = await keychain.setKeyAsync(account, value);
@@ -6,6 +6,7 @@
6
6
  * Shared by the sandbox bash tool and skill sandbox runner.
7
7
  */
8
8
  import { getGatewayInternalBaseUrl, getIngressPublicBaseUrl } from '../../config/env.js';
9
+ import { isSigningKeyInitialized, mintEdgeRelayToken } from '../../runtime/auth/token-service.js';
9
10
 
10
11
  const SAFE_ENV_VARS = [
11
12
  'PATH',
@@ -42,5 +43,11 @@ export function buildSanitizedEnv(): Record<string, string> {
42
43
  // back to the internal base so commands remain functional in local-only mode.
43
44
  const publicGatewayBase = getIngressPublicBaseUrl()?.replace(/\/+$/, '');
44
45
  env.GATEWAY_BASE_URL = publicGatewayBase || internalGatewayBase;
46
+ // Mint a short-lived JWT for gateway authentication when the signing key
47
+ // is available (daemon context). CLI-only contexts without daemon startup
48
+ // will not have the key initialized — gracefully skip.
49
+ if (isSigningKeyInitialized()) {
50
+ env.GATEWAY_AUTH_TOKEN = mintEdgeRelayToken();
51
+ }
45
52
  return env;
46
53
  }