vellum 0.2.9 → 0.2.11

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.
Files changed (61) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +2 -2
  3. package/scripts/capture-x-graphql.ts +1 -18
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +110 -0
  5. package/src/__tests__/call-bridge.test.ts +40 -0
  6. package/src/__tests__/call-state.test.ts +41 -0
  7. package/src/__tests__/forbidden-legacy-symbols.test.ts +8 -6
  8. package/src/__tests__/gateway-only-enforcement.test.ts +13 -89
  9. package/src/__tests__/home-base-bootstrap.test.ts +13 -8
  10. package/src/__tests__/intent-routing.test.ts +2 -5
  11. package/src/__tests__/ipc-snapshot.test.ts +49 -0
  12. package/src/__tests__/onboarding-starter-tasks.test.ts +12 -2
  13. package/src/__tests__/prebuilt-home-base-seed.test.ts +9 -5
  14. package/src/__tests__/relay-server.test.ts +55 -0
  15. package/src/__tests__/skills.test.ts +83 -0
  16. package/src/__tests__/system-prompt.test.ts +2 -24
  17. package/src/__tests__/twilio-provider.test.ts +36 -0
  18. package/src/__tests__/twilio-routes.test.ts +108 -0
  19. package/src/calls/call-orchestrator.ts +25 -5
  20. package/src/calls/call-state.ts +23 -0
  21. package/src/calls/relay-server.ts +56 -1
  22. package/src/calls/twilio-config.ts +9 -13
  23. package/src/calls/twilio-provider.ts +6 -1
  24. package/src/calls/twilio-routes.ts +10 -1
  25. package/src/cli/core-commands.ts +12 -4
  26. package/src/config/bundled-skills/app-builder/SKILL.md +57 -1
  27. package/src/config/bundled-skills/document/SKILL.md +11 -3
  28. package/src/config/bundled-skills/followups/icon.svg +24 -0
  29. package/src/config/bundled-skills/messaging/SKILL.md +7 -3
  30. package/src/config/bundled-skills/public-ingress/SKILL.md +183 -0
  31. package/src/config/bundled-skills/self-upgrade/SKILL.md +4 -10
  32. package/src/config/defaults.ts +1 -1
  33. package/src/config/schema.ts +4 -7
  34. package/src/config/system-prompt.ts +64 -360
  35. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -1
  36. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +5 -1
  37. package/src/config/vellum-skills/telegram-setup/SKILL.md +2 -1
  38. package/src/daemon/handlers/config.ts +20 -9
  39. package/src/daemon/handlers/home-base.ts +3 -2
  40. package/src/daemon/handlers/identity.ts +127 -0
  41. package/src/daemon/handlers/index.ts +4 -0
  42. package/src/daemon/handlers/workspace-files.ts +75 -0
  43. package/src/daemon/ipc-contract-inventory.json +16 -4
  44. package/src/daemon/ipc-contract.ts +62 -2
  45. package/src/daemon/lifecycle.ts +16 -0
  46. package/src/daemon/session-notifiers.ts +29 -0
  47. package/src/daemon/session-surfaces.ts +5 -2
  48. package/src/daemon/session-tool-setup.ts +15 -4
  49. package/src/home-base/bootstrap.ts +3 -1
  50. package/src/home-base/prebuilt/seed.ts +16 -5
  51. package/src/inbound/public-ingress-urls.ts +15 -4
  52. package/src/runtime/http-server.ts +123 -20
  53. package/src/security/oauth2.ts +19 -161
  54. package/src/tools/browser/auto-navigate.ts +2 -2
  55. package/src/tools/browser/x-auto-navigate.ts +1 -1
  56. package/src/tools/claude-code/claude-code.ts +1 -1
  57. package/src/tools/system/version.ts +43 -0
  58. package/src/tools/tasks/work-item-run.ts +1 -1
  59. package/src/tools/terminal/parser.ts +29 -7
  60. package/src/tools/tool-manifest.ts +2 -0
  61. package/src/tools/ui-surface/definitions.ts +9 -2
@@ -12,13 +12,16 @@ import {
12
12
  getCallSession,
13
13
  updateCallSession,
14
14
  recordCallEvent,
15
+ expirePendingQuestions,
15
16
  } from './call-store.js';
16
17
  import { CallOrchestrator } from './call-orchestrator.js';
18
+ import { fireCallTranscriptNotifier, fireCallCompletionNotifier } from './call-state.js';
17
19
  import {
18
20
  extractPromptSpeakerMetadata,
19
21
  SpeakerIdentityTracker,
20
22
  type PromptSpeakerContext,
21
23
  } from './speaker-identification.js';
24
+ import { isTerminalState } from './call-state-machine.js';
22
25
 
23
26
  const log = getLogger('relay-server');
24
27
 
@@ -228,6 +231,44 @@ export class RelayConnection {
228
231
  log.info({ callSessionId: this.callSessionId }, 'RelayConnection destroyed');
229
232
  }
230
233
 
234
+ /**
235
+ * Handle transport-level close from the relay websocket.
236
+ *
237
+ * Twilio status callbacks are best-effort; if they are delayed or absent,
238
+ * we still finalize the call lifecycle from the relay close signal.
239
+ */
240
+ handleTransportClosed(code?: number, reason?: string): void {
241
+ const session = getCallSession(this.callSessionId);
242
+ if (!session) return;
243
+ if (isTerminalState(session.status)) return;
244
+
245
+ const isNormalClose = code === 1000;
246
+ if (isNormalClose) {
247
+ updateCallSession(this.callSessionId, {
248
+ status: 'completed',
249
+ endedAt: Date.now(),
250
+ });
251
+ recordCallEvent(this.callSessionId, 'call_ended', {
252
+ reason: reason || 'relay_closed',
253
+ closeCode: code,
254
+ });
255
+ } else {
256
+ const detail = reason || (code ? `relay_closed_${code}` : 'relay_closed_abnormal');
257
+ updateCallSession(this.callSessionId, {
258
+ status: 'failed',
259
+ endedAt: Date.now(),
260
+ lastError: `Relay websocket closed unexpectedly: ${detail}`,
261
+ });
262
+ recordCallEvent(this.callSessionId, 'call_failed', {
263
+ reason: detail,
264
+ closeCode: code,
265
+ });
266
+ }
267
+
268
+ expirePendingQuestions(this.callSessionId);
269
+ fireCallCompletionNotifier(session.conversationId, this.callSessionId);
270
+ }
271
+
231
272
  // ── Private handlers ─────────────────────────────────────────────
232
273
 
233
274
  private async handleSetup(msg: RelaySetupMessage): Promise<void> {
@@ -239,7 +280,16 @@ export class RelayConnection {
239
280
  // Store the callSid association on the call session
240
281
  const session = getCallSession(this.callSessionId);
241
282
  if (session) {
242
- updateCallSession(this.callSessionId, { providerCallSid: msg.callSid });
283
+ const updates: Parameters<typeof updateCallSession>[1] = {
284
+ providerCallSid: msg.callSid,
285
+ };
286
+ if (!isTerminalState(session.status) && session.status !== 'in_progress' && session.status !== 'waiting_on_user') {
287
+ updates.status = 'in_progress';
288
+ if (!session.startedAt) {
289
+ updates.startedAt = Date.now();
290
+ }
291
+ }
292
+ updateCallSession(this.callSessionId, updates);
243
293
  }
244
294
 
245
295
  recordCallEvent(this.callSessionId, 'call_connected', {
@@ -286,6 +336,11 @@ export class RelayConnection {
286
336
  speakerSource: speaker.source,
287
337
  });
288
338
 
339
+ const session = getCallSession(this.callSessionId);
340
+ if (session) {
341
+ fireCallTranscriptNotifier(session.conversationId, this.callSessionId, 'caller', msg.voicePrompt);
342
+ }
343
+
289
344
  // Route to orchestrator for LLM-driven response
290
345
  if (this.orchestrator) {
291
346
  await this.orchestrator.handleCallerUtterance(msg.voicePrompt, speaker);
@@ -20,20 +20,16 @@ export function getTwilioConfig(): TwilioConfig {
20
20
  const config = loadConfig();
21
21
  const webhookBaseUrl = getPublicBaseUrl(config);
22
22
 
23
- // In gateway_only mode, ignore TWILIO_WSS_BASE_URL and always use the
24
- // centralized relay URL derived from the public ingress base URL.
23
+ // Always use the centralized relay URL derived from the public ingress base URL.
24
+ // TWILIO_WSS_BASE_URL is ignored.
25
25
  let wssBaseUrl: string;
26
- if (config.ingress.mode === 'gateway_only') {
27
- if (process.env.TWILIO_WSS_BASE_URL) {
28
- log.warn('TWILIO_WSS_BASE_URL env var is ignored in gateway-only mode. Relay URL is derived from ingress.publicBaseUrl.');
29
- }
30
- try {
31
- wssBaseUrl = getTwilioRelayUrl(config);
32
- } catch {
33
- wssBaseUrl = '';
34
- }
35
- } else {
36
- wssBaseUrl = process.env.TWILIO_WSS_BASE_URL || '';
26
+ if (process.env.TWILIO_WSS_BASE_URL) {
27
+ log.warn('TWILIO_WSS_BASE_URL env var is ignored. Relay URL is derived from ingress.publicBaseUrl.');
28
+ }
29
+ try {
30
+ wssBaseUrl = getTwilioRelayUrl(config);
31
+ } catch {
32
+ wssBaseUrl = '';
37
33
  }
38
34
 
39
35
  if (!accountSid || !authToken) {
@@ -45,8 +45,13 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
45
45
  To: opts.to,
46
46
  Url: opts.webhookUrl,
47
47
  StatusCallback: opts.statusCallbackUrl,
48
- StatusCallbackEvent: 'initiated ringing answered completed',
49
48
  });
49
+ // Twilio expects repeated StatusCallbackEvent params, not a single
50
+ // space-delimited string.
51
+ body.append('StatusCallbackEvent', 'initiated');
52
+ body.append('StatusCallbackEvent', 'ringing');
53
+ body.append('StatusCallbackEvent', 'answered');
54
+ body.append('StatusCallbackEvent', 'completed');
50
55
 
51
56
  const reservedKeys = new Set(['From', 'To', 'Url', 'StatusCallback', 'StatusCallbackEvent']);
52
57
  if (opts.customParams) {
@@ -24,6 +24,7 @@ import { isTerminalState } from './call-state-machine.js';
24
24
  import { getTwilioConfig } from './twilio-config.js';
25
25
  import { loadConfig } from '../config/loader.js';
26
26
  import { getTwilioRelayUrl } from '../inbound/public-ingress-urls.js';
27
+ import { fireCallCompletionNotifier } from './call-state.js';
27
28
 
28
29
  const log = getLogger('twilio-routes');
29
30
 
@@ -74,9 +75,12 @@ export function resolveRelayUrl(wssBaseUrl: string, webhookBaseUrl: string): str
74
75
  */
75
76
  function mapTwilioStatus(twilioStatus: string): CallStatus | null {
76
77
  switch (twilioStatus) {
78
+ case 'initiated':
77
79
  case 'queued':
80
+ return 'initiated';
78
81
  case 'ringing':
79
82
  return 'ringing';
83
+ case 'answered':
80
84
  case 'in-progress':
81
85
  return 'in_progress';
82
86
  case 'completed':
@@ -189,6 +193,8 @@ export async function handleStatusCallback(req: Request): Promise<Response> {
189
193
  }
190
194
 
191
195
  try {
196
+ const wasTerminal = isTerminalState(session.status);
197
+
192
198
  // Build updates
193
199
  const updates: Parameters<typeof updateCallSession>[1] = {
194
200
  status: mappedStatus,
@@ -218,6 +224,10 @@ export async function handleStatusCallback(req: Request): Promise<Response> {
218
224
  // Expire pending questions on terminal status
219
225
  if (isTerminal) {
220
226
  expirePendingQuestions(session.id);
227
+
228
+ if (!wasTerminal) {
229
+ fireCallCompletionNotifier(session.conversationId, session.id);
230
+ }
221
231
  }
222
232
 
223
233
  // Mark the claim as permanently processed so it never expires.
@@ -255,4 +265,3 @@ export async function handleConnectAction(_req: Request): Promise<Response> {
255
265
  },
256
266
  );
257
267
  }
258
-
@@ -555,15 +555,23 @@ export function registerDoctorCommand(program: Command): void {
555
555
 
556
556
  // 11. WASM files
557
557
  const wasmFiles = [
558
- 'node_modules/web-tree-sitter/web-tree-sitter.wasm',
559
- 'node_modules/tree-sitter-bash/tree-sitter-bash.wasm',
558
+ { pkg: 'web-tree-sitter', file: 'web-tree-sitter.wasm' },
559
+ { pkg: 'tree-sitter-bash', file: 'tree-sitter-bash.wasm' },
560
560
  ];
561
561
  let wasmOk = true;
562
562
  const missingWasm: string[] = [];
563
563
  for (const wasm of wasmFiles) {
564
- const fullPath = `${import.meta.dirname}/../../${wasm}`;
564
+ const dir = import.meta.dirname ?? __dirname;
565
+ let fullPath = `${dir}/../../node_modules/${wasm.pkg}/${wasm.file}`;
566
+ // In compiled binaries, fall back to Resources/ or next to the binary
567
+ if (!existsSync(fullPath) && dir.startsWith('/$bunfs/')) {
568
+ const { dirname: pathDirname, join: pathJoin } = await import('node:path');
569
+ const execDir = pathDirname(process.execPath);
570
+ const resourcesPath = pathJoin(execDir, '..', 'Resources', wasm.file);
571
+ fullPath = existsSync(resourcesPath) ? resourcesPath : pathJoin(execDir, wasm.file);
572
+ }
565
573
  if (!existsSync(fullPath)) {
566
- missingWasm.push(wasm);
574
+ missingWasm.push(wasm.file);
567
575
  wasmOk = false;
568
576
  } else {
569
577
  try {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "App Builder"
3
- description: "Create polished, professional local apps with HTML/CSS/JS"
3
+ description: "Build interactive apps, dashboards, calculators, games, trackers, tools, landing pages, and data visualizations with HTML/CSS/JS. Use when the user says build, create, or make an app/dashboard/calculator/game."
4
4
  ---
5
5
 
6
6
  You are an expert app builder and visual designer. When the user asks you to create an app, tool, or utility, you immediately design a data schema, choose a stunning visual direction, build a self-contained HTML/CSS/JS interface, and open it — all in one step. You don't discuss or ask for permission to be creative. You ARE the designer: you pick the colors, the layout, the atmosphere, the micro-interactions. Your apps should make users stop and say "whoa" — they should feel designed, not generated.
@@ -1346,3 +1346,59 @@ Every app should include: search/filter, toast notifications for all CRUD operat
1346
1346
  - Never let a failed data operation silently pass — always show a toast or inline error message.
1347
1347
  - If the page loads with no data, show a designed empty state (`.v-empty-state`) — never a blank screen.
1348
1348
  - For forms, show validation errors inline next to the relevant field, not as an alert.
1349
+
1350
+ ## Actionable UI
1351
+
1352
+ When the user wants to triage, manage, or bulk-act on a collection of items (emails, files, notifications, tasks, subscriptions, contacts), generate an interactive UI that lets them review, select, and act on items directly.
1353
+
1354
+ ### Pattern
1355
+ 1. **Fetch data** — use the relevant tools to gather the items
1356
+ 2. **Generate interactive UI** — render a `dynamic_page` with selectable items and action buttons
1357
+ 3. **User selects + clicks action** — the UI sends a `surfaceAction` with an action ID and selected item IDs
1358
+ 4. **Execute tools** — parse the action, call the appropriate tools
1359
+ 5. **Update UI** — use `ui_update` to remove processed items and show feedback via `widgets.toast()`
1360
+
1361
+ ### HTML structure
1362
+ Choose the best layout for the data: grouped cards with checkboxes, data tables with selectable rows, kanban columns, stacked list items with inline actions, or any creative layout. The key constraint: items must be selectable and action buttons must call `sendAction` with the selected item IDs.
1363
+
1364
+ ### CSS building blocks
1365
+ - `.v-action-bar` — sticky bar at top, auto-hidden when nothing selected. Contains `.v-action-bar-count` and `.v-action-bar-buttons`
1366
+ - `.v-action-progress` — inline progress bar for bulk operations
1367
+ - `.v-group-header` / `.v-group-body` — collapsible grouped sections
1368
+ - `.v-row-removing` — fade-out + slide animation for processed items
1369
+
1370
+ ### Action data conventions
1371
+ - Use semantic action IDs: `archive`, `unsubscribe`, `delete`, `move`, `mark_read`
1372
+ - Always include selected item IDs: `sendAction("archive", { ids: ["msg_1", "msg_2"] })`
1373
+
1374
+ ### Processing flow
1375
+ 1. Parse the `surfaceAction` to get the action ID and data
1376
+ 2. Use `vellum.confirm(title, message)` for destructive actions before executing
1377
+ 3. Call the relevant tools with the item IDs
1378
+ 4. Use `ui_update` to remove processed items and update counts
1379
+ 5. Show `widgets.toast()` for feedback
1380
+
1381
+ ### Error handling
1382
+ - Handle partial failures: remove successful items, toast count, keep failed items selectable for retry
1383
+
1384
+ ### Surface lifecycle
1385
+ - Use `ui_show` with `display: "panel"` to keep the surface open alongside chat
1386
+ - Use `widgets.groupedSelect()` for grouped multi-select with action bar
1387
+ - Use `widgets.removeItems()` to animate processed items out
1388
+
1389
+ ## Home Base
1390
+
1391
+ Home Base starts from a prebuilt scaffold. When updating Home Base, preserve required task-lane anchors and apply changes through `app_file_edit` or `app_file_write`.
1392
+
1393
+ Home Base buttons send prefilled natural-language prompts through `vellum.sendAction`. Treat these as normal user messages, not as direct execution commands.
1394
+ - For appearance changes: keep customization color-first, ask for explicit confirmation before applying a full-dashboard update.
1395
+ - For optional capability setup tasks (voice/computer control/ambient): keep them user-initiated and request permissions only when required for the chosen path.
1396
+ - If a prompt is underspecified, ask one brief follow-up and continue.
1397
+
1398
+ ## External Links
1399
+
1400
+ When building apps with linkable items (search results, product cards, bookings), use `vellum.openLink(url, metadata)` to make them clickable. Construct deep-link URLs when possible (airline booking pages, product pages, hotel reservations). Include `metadata.provider` and `metadata.type` for context: `vellum.openLink("https://delta.com/book?flight=DL123", {provider: "delta", type: "booking"})`.
1401
+
1402
+ ## Branding
1403
+
1404
+ A "Built on Vellum" badge is auto-injected into every dynamic page and app at the bottom-right corner. Do NOT add your own "Built on Vellum" or "Powered by Vellum" text — the badge is handled automatically by the rendering layer.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "Document"
3
- description: "Document creation and editing for UI surfaces"
3
+ description: "Write, draft, or compose long-form text (blog posts, articles, essays, reports, guides). Use when the user says write, draft, compose, or create a blog/article/essay."
4
4
  metadata: {"vellum": {"emoji": "📄"}}
5
5
  ---
6
6
 
@@ -11,8 +11,16 @@ Create and edit long-form documents using the built-in rich text editor. Documen
11
11
  - **document_create** — Opens a new document editor with an optional title and initial Markdown content. Returns a `surface_id` for subsequent updates.
12
12
  - **document_update** — Updates content in an open document editor by `surface_id`. Supports `replace` (overwrite) and `append` (add to end) modes.
13
13
 
14
+ ## Workflow
15
+
16
+ 1. **Create the document**: Call `document_create` with a title (inferred from the request). Call the tool immediately, not after conversational preamble.
17
+ 2. **Write content in Markdown**: Use proper structure (`#` for titles, `##` for sections), **bold**, *italic*, code blocks, tables, lists, blockquotes as appropriate.
18
+ 3. **CRITICAL — Stream content in chunks**: Call `document_update` MULTIPLE times, not just once. Break content into logical chunks (paragraphs, sections, or every 200-300 words). Call `document_update` with `mode: "append"` for EACH chunk separately. The user experiences real-time content appearing as you write.
19
+ 4. **Respond to edits**: When the user requests changes via the docked chat, use `document_update` with `replace` for full rewrites or `append` for additions.
20
+
14
21
  ## Usage Notes
15
22
 
16
- - Use `document_create` when the user asks to write a blog post, article, or any long-form content.
17
- - After creating a document, use `document_update` with the returned `surface_id` to stream or edit content.
18
23
  - The `mode` parameter on `document_update` defaults to `append`.
24
+ - Documents are automatically saved and accessible via the Generated panel.
25
+ - Users can manually edit documents at any time.
26
+ - Write in clear, engaging prose. Use active voice, vary sentence structure, and break content into logical sections with descriptive headings.
@@ -0,0 +1,24 @@
1
+ <svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
2
+ <rect width="16" height="16" fill="#f5f5f5"/>
3
+
4
+ <!-- Envelope body -->
5
+ <rect x="2" y="4" width="12" height="8" fill="#4a90e2" stroke="#2c5aa0" stroke-width="1"/>
6
+
7
+ <!-- Envelope flap -->
8
+ <polygon points="2,4 8,8 14,4" fill="#357abd"/>
9
+
10
+ <!-- Paper/message inside -->
11
+ <rect x="3" y="6" width="10" height="5" fill="#ffffff"/>
12
+
13
+ <!-- Lines on paper -->
14
+ <line x1="4" y1="7" x2="12" y2="7" stroke="#d0d0d0" stroke-width="1"/>
15
+ <line x1="4" y1="8.5" x2="12" y2="8.5" stroke="#d0d0d0" stroke-width="1"/>
16
+ <line x1="4" y1="10" x2="10" y2="10" stroke="#d0d0d0" stroke-width="1"/>
17
+
18
+ <!-- Clock overlay (top right) -->
19
+ <circle cx="11" cy="3" r="2.5" fill="#ff6b6b" stroke="#c92a2a" stroke-width="0.5"/>
20
+
21
+ <!-- Clock hands -->
22
+ <line x1="11" y1="1.5" x2="11" y2="2.2" stroke="#ffffff" stroke-width="0.8" stroke-linecap="round"/>
23
+ <line x1="12.2" y1="3" x2="11.7" y2="3" stroke="#ffffff" stroke-width="0.8" stroke-linecap="round"/>
24
+ </svg>
@@ -11,9 +11,13 @@ You are a unified messaging assistant with access to multiple platforms (Slack,
11
11
 
12
12
  Before using any messaging tool, verify that the platform is connected by calling `messaging_auth_test` with the appropriate `platform` parameter. If the call fails with a token/authorization error, follow the steps below.
13
13
 
14
+ ### Public Ingress (required for all platforms)
15
+
16
+ Gmail, Slack, and Telegram setup all require a publicly reachable URL for OAuth callbacks or webhook delivery. The **public-ingress** skill handles ngrok tunnel setup and persists the URL as `ingress.publicBaseUrl`. Each setup skill below declares `public-ingress` as a dependency and will prompt you to run it if `ingress.publicBaseUrl` is not configured.
17
+
14
18
  ### Gmail
15
19
  1. **Try connecting directly first.** Call `credential_store` with `action: "oauth2_connect"` and `service: "gmail"`. The tool auto-fills Google's OAuth endpoints and looks up any previously stored client credentials — so this single call may be all that's needed.
16
- 2. **If it fails because no client_id is found:** The user needs to create Google Cloud OAuth credentials first. Install and load the **google-oauth-setup** skill:
20
+ 2. **If it fails because no client_id is found:** The user needs to create Google Cloud OAuth credentials first. Install and load the **google-oauth-setup** skill (which depends on **public-ingress** for the redirect URI):
17
21
  - Call `vellum_skills_catalog` with `action: "install"` and `skill_id: "google-oauth-setup"`.
18
22
  - Then call `skill_load` with `skill: "google-oauth-setup"`.
19
23
  - Tell the user: *"Gmail isn't connected yet. I've loaded a setup guide that will walk you through creating Google credentials and connecting your account."*
@@ -21,14 +25,14 @@ Before using any messaging tool, verify that the platform is connected by callin
21
25
 
22
26
  ### Slack
23
27
  1. **Try connecting directly first.** Call `credential_store` with `action: "oauth2_connect"` and `service: "slack"`. The tool auto-fills Slack's OAuth endpoints and looks up any previously stored client credentials.
24
- 2. **If it fails because no client_id is found:** The user needs to create a Slack App first. Install and load the **slack-oauth-setup** skill:
28
+ 2. **If it fails because no client_id is found:** The user needs to create a Slack App first. Install and load the **slack-oauth-setup** skill (which depends on **public-ingress** for the redirect URI):
25
29
  - Call `vellum_skills_catalog` with `action: "install"` and `skill_id: "slack-oauth-setup"`.
26
30
  - Then call `skill_load` with `skill: "slack-oauth-setup"`.
27
31
  - Tell the user: *"Slack isn't connected yet. I've loaded a setup guide that will walk you through creating a Slack App and connecting your workspace."*
28
32
  3. **If the user provides client_id and client_secret directly in chat:** Call `credential_store` with `action: "oauth2_connect"`, `service: "slack"`, `client_id`, and `client_secret`. Everything else is auto-filled. Note: Slack always requires a client_secret.
29
33
 
30
34
  ### Telegram
31
- Telegram uses a bot token (not OAuth). Install and load the **telegram-setup** skill which automates the full setup:
35
+ Telegram uses a bot token (not OAuth). Install and load the **telegram-setup** skill (which depends on **public-ingress** for the webhook URL) which automates the full setup:
32
36
  - Call `vellum_skills_catalog` with `action: "install"` and `skill_id: "telegram-setup"`.
33
37
  - Then call `skill_load` with `skill: "telegram-setup"`.
34
38
  - Tell the user: *"I've loaded a setup guide for Telegram. It will walk you through connecting a Telegram bot to your assistant."*
@@ -0,0 +1,183 @@
1
+ ---
2
+ name: "Public Ingress"
3
+ description: "Set up and manage ngrok-based public ingress for webhooks and OAuth callbacks via ingress.publicBaseUrl"
4
+ user-invocable: true
5
+ metadata: {"vellum": {"emoji": "🌍"}}
6
+ ---
7
+
8
+ You are setting up and managing a public ingress tunnel so that external services (Telegram webhooks, OAuth callbacks, etc.) can reach the local Vellum gateway. This skill uses ngrok to create a secure tunnel and persists the public URL as `ingress.publicBaseUrl`.
9
+
10
+ ## Overview
11
+
12
+ The Vellum gateway listens locally and needs a publicly reachable URL for:
13
+ - Telegram webhook delivery
14
+ - Google/Slack OAuth redirect callbacks
15
+ - Any other inbound webhook traffic
16
+
17
+ This skill installs ngrok, configures authentication, starts a tunnel, discovers the public URL, and saves it to the assistant's ingress config.
18
+
19
+ ## Step 1: Check Current Ingress Status
20
+
21
+ First, check whether ingress is already configured:
22
+
23
+ ```bash
24
+ vellum config get ingress.publicBaseUrl
25
+ ```
26
+
27
+ Also determine the local gateway target. The gateway listens on `http://127.0.0.1:${GATEWAY_PORT:-7830}` by default.
28
+
29
+ If `ingress.publicBaseUrl` is already set and the tunnel is running (check via `curl -s http://127.0.0.1:4040/api/tunnels`), tell the user the current status and ask if they want to reconfigure or if this is sufficient.
30
+
31
+ ## Step 2: Install ngrok
32
+
33
+ Check if ngrok is installed:
34
+
35
+ ```bash
36
+ ngrok version
37
+ ```
38
+
39
+ If not installed, install it:
40
+
41
+ **macOS (Homebrew):**
42
+ ```bash
43
+ brew install ngrok/ngrok/ngrok
44
+ ```
45
+
46
+ **Linux (snap):**
47
+ ```bash
48
+ sudo snap install ngrok
49
+ ```
50
+
51
+ **Linux (apt — alternative):**
52
+ ```bash
53
+ curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null
54
+ echo "deb https://ngrok-agent.s3.amazonaws.com buster main" | sudo tee /etc/apt/sources.list.d/ngrok.list
55
+ sudo apt update && sudo apt install ngrok
56
+ ```
57
+
58
+ After installation, verify with `ngrok version`.
59
+
60
+ ## Step 3: Authenticate ngrok
61
+
62
+ Check if ngrok already has an auth token configured:
63
+
64
+ ```bash
65
+ ngrok config check
66
+ ```
67
+
68
+ If not authenticated:
69
+
70
+ 1. Tell the user: "You need an ngrok account to create tunnels. If you don't have one, sign up at https://dashboard.ngrok.com/signup — it's free."
71
+ 2. Once they have an account, ask them to paste their auth token directly in chat. They can find it at https://dashboard.ngrok.com/get-started/your-authtoken.
72
+
73
+ 3. Once the user provides the token, configure ngrok with it immediately:
74
+ ```bash
75
+ ngrok config add-authtoken <token>
76
+ ```
77
+
78
+ Verify authentication succeeded by checking `ngrok config check` again.
79
+
80
+ ## Step 4: Start the Tunnel
81
+
82
+ Before starting, check for an existing ngrok process to avoid duplicates:
83
+
84
+ ```bash
85
+ curl -s http://127.0.0.1:4040/api/tunnels 2>/dev/null
86
+ ```
87
+
88
+ If a tunnel is already running, check whether it points to the correct local target. If so, skip to Step 5. If it points elsewhere, stop it first:
89
+
90
+ ```bash
91
+ pkill -f ngrok || true
92
+ sleep 1
93
+ ```
94
+
95
+ Start ngrok in the background, tunneling to the local gateway:
96
+
97
+ ```bash
98
+ nohup ngrok http http://127.0.0.1:${GATEWAY_PORT:-7830} --log=stdout > /tmp/ngrok.log 2>&1 &
99
+ echo $! > /tmp/ngrok.pid
100
+ ```
101
+
102
+ Wait a few seconds for the tunnel to establish:
103
+
104
+ ```bash
105
+ sleep 3
106
+ ```
107
+
108
+ ## Step 5: Discover the Public URL
109
+
110
+ Query the ngrok local API for the tunnel's public URL:
111
+
112
+ ```bash
113
+ curl -s http://127.0.0.1:4040/api/tunnels | python3 -c "
114
+ import sys, json
115
+ data = json.load(sys.stdin)
116
+ tunnels = data.get('tunnels', [])
117
+ for t in tunnels:
118
+ url = t.get('public_url', '')
119
+ if url.startswith('https://'):
120
+ print(url)
121
+ sys.exit(0)
122
+ for t in tunnels:
123
+ url = t.get('public_url', '')
124
+ if url:
125
+ print(url)
126
+ sys.exit(0)
127
+ print('ERROR: no tunnel found')
128
+ sys.exit(1)
129
+ "
130
+ ```
131
+
132
+ If no tunnel is found, check `/tmp/ngrok.log` for errors and report them to the user.
133
+
134
+ ## Step 6: Persist the Ingress Setting
135
+
136
+ Save the discovered public URL and enable ingress:
137
+
138
+ ```bash
139
+ vellum config set ingress.publicBaseUrl "<public-url>"
140
+ vellum config set ingress.enabled true
141
+ ```
142
+
143
+ Verify it was saved:
144
+
145
+ ```bash
146
+ vellum config get ingress.publicBaseUrl
147
+ vellum config get ingress.enabled
148
+ ```
149
+
150
+ ## Step 7: Report Completion
151
+
152
+ Summarize the setup:
153
+
154
+ - **Public URL:** `<the-url>` (this is your `ingress.publicBaseUrl`)
155
+ - **Local gateway:** `http://127.0.0.1:${GATEWAY_PORT:-7830}`
156
+ - **ngrok dashboard:** http://127.0.0.1:4040
157
+
158
+ Provide useful follow-up commands:
159
+
160
+ - **Check tunnel status:** `curl -s http://127.0.0.1:4040/api/tunnels | python3 -c "import sys,json; [print(t['public_url']) for t in json.load(sys.stdin)['tunnels']]"`
161
+ - **View ngrok logs:** `cat /tmp/ngrok.log`
162
+ - **Restart tunnel:** `pkill -f ngrok; sleep 1; nohup ngrok http http://127.0.0.1:${GATEWAY_PORT:-7830} --log=stdout > /tmp/ngrok.log 2>&1 &`
163
+ - **Stop tunnel:** `pkill -f ngrok`
164
+ - **Rotate URL:** Stop and restart ngrok (free tier assigns a new URL each time; update `ingress.publicBaseUrl` afterward)
165
+
166
+ **Important:** On ngrok's free tier, the public URL changes every time the tunnel restarts. After restarting, re-run this skill or manually update `ingress.publicBaseUrl` and any registered webhooks (e.g., Telegram).
167
+
168
+ ## Troubleshooting
169
+
170
+ ### ngrok not installed
171
+ Run the install commands in Step 2. On macOS, make sure Homebrew is installed first (`brew --version`).
172
+
173
+ ### Auth token invalid or expired
174
+ Sign in to https://dashboard.ngrok.com, copy a fresh token from the "Your Authtoken" page, and re-run Step 3.
175
+
176
+ ### ngrok API (port 4040) not responding
177
+ The ngrok process may not be running. Check with `ps aux | grep ngrok`. If not running, start it per Step 4. If running but 4040 is unresponsive, check `/tmp/ngrok.log` for errors.
178
+
179
+ ### Gateway not reachable on local target
180
+ Ensure the Vellum gateway is running on `http://127.0.0.1:${GATEWAY_PORT:-7830}`. Check with `curl -s http://127.0.0.1:${GATEWAY_PORT:-7830}/health`. If not running, start the assistant daemon first.
181
+
182
+ ### "Too many connections" or tunnel limit errors
183
+ ngrok's free tier allows one tunnel at a time. Stop any other ngrok tunnels before starting a new one.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "Self Upgrade"
3
- description: "Upgrade velly to the latest version, restart the daemon, and restart the gateway"
3
+ description: "Upgrade vellum to the latest version, restart the daemon, and restart the gateway"
4
4
  user-invocable: true
5
5
  metadata: {"vellum": {"emoji": "⬆️"}}
6
6
  ---
@@ -15,16 +15,10 @@ vellum --version
15
15
 
16
16
  Save this value to report later.
17
17
 
18
- ## Step 2: Install the latest velly
18
+ ## Step 2: Install the latest vellum
19
19
 
20
20
  ```bash
21
- bun update -g velly
22
- ```
23
-
24
- If `velly` was not installed globally via bun, try:
25
-
26
- ```bash
27
- npm update -g velly
21
+ bun install -g vellum@latest
28
22
  ```
29
23
 
30
24
  After updating, verify the new version:
@@ -68,7 +62,7 @@ vellum daemon status
68
62
  ## After Upgrade
69
63
 
70
64
  Report back to the user with:
71
- - The previous and new velly version
65
+ - The previous and new vellum version
72
66
  - Daemon status (running, PID)
73
67
  - Gateway status (restarted or not found)
74
68
  - Any errors encountered during the process
@@ -228,7 +228,7 @@ export const DEFAULT_CONFIG: AssistantConfig = {
228
228
  },
229
229
  },
230
230
  ingress: {
231
+ enabled: false,
231
232
  publicBaseUrl: '',
232
- mode: 'gateway_only' as const,
233
233
  },
234
234
  };
@@ -9,7 +9,6 @@ const VALID_SANDBOX_BACKENDS = ['native', 'docker'] as const;
9
9
  const VALID_DOCKER_NETWORKS = ['none', 'bridge'] as const;
10
10
  const VALID_PERMISSIONS_MODES = ['legacy', 'strict'] as const;
11
11
  const VALID_CALL_PROVIDERS = ['twilio'] as const;
12
- const VALID_INGRESS_MODES = ['gateway_only', 'compat'] as const;
13
12
 
14
13
  export const TimeoutConfigSchema = z.object({
15
14
  shellMaxTimeoutSec: z
@@ -924,14 +923,12 @@ export const SkillsConfigSchema = z.object({
924
923
  });
925
924
 
926
925
  export const IngressConfigSchema = z.object({
926
+ enabled: z
927
+ .boolean({ error: 'ingress.enabled must be a boolean' })
928
+ .default(false),
927
929
  publicBaseUrl: z
928
930
  .string({ error: 'ingress.publicBaseUrl must be a string' })
929
931
  .default(''),
930
- mode: z
931
- .enum(VALID_INGRESS_MODES, {
932
- error: `ingress.mode must be one of: ${VALID_INGRESS_MODES.join(', ')}`,
933
- })
934
- .default('gateway_only'),
935
932
  });
936
933
 
937
934
  export const AssistantConfigSchema = z.object({
@@ -1183,8 +1180,8 @@ export const AssistantConfigSchema = z.object({
1183
1180
  },
1184
1181
  }),
1185
1182
  ingress: IngressConfigSchema.default({
1183
+ enabled: false,
1186
1184
  publicBaseUrl: '',
1187
- mode: 'gateway_only',
1188
1185
  }),
1189
1186
  }).superRefine((config, ctx) => {
1190
1187
  if (config.contextWindow.targetInputTokens >= config.contextWindow.maxInputTokens) {