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.
- package/bun.lock +2 -2
- package/package.json +2 -2
- package/scripts/capture-x-graphql.ts +1 -18
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +110 -0
- package/src/__tests__/call-bridge.test.ts +40 -0
- package/src/__tests__/call-state.test.ts +41 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +8 -6
- package/src/__tests__/gateway-only-enforcement.test.ts +13 -89
- package/src/__tests__/home-base-bootstrap.test.ts +13 -8
- package/src/__tests__/intent-routing.test.ts +2 -5
- package/src/__tests__/ipc-snapshot.test.ts +49 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +12 -2
- package/src/__tests__/prebuilt-home-base-seed.test.ts +9 -5
- package/src/__tests__/relay-server.test.ts +55 -0
- package/src/__tests__/skills.test.ts +83 -0
- package/src/__tests__/system-prompt.test.ts +2 -24
- package/src/__tests__/twilio-provider.test.ts +36 -0
- package/src/__tests__/twilio-routes.test.ts +108 -0
- package/src/calls/call-orchestrator.ts +25 -5
- package/src/calls/call-state.ts +23 -0
- package/src/calls/relay-server.ts +56 -1
- package/src/calls/twilio-config.ts +9 -13
- package/src/calls/twilio-provider.ts +6 -1
- package/src/calls/twilio-routes.ts +10 -1
- package/src/cli/core-commands.ts +12 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +57 -1
- package/src/config/bundled-skills/document/SKILL.md +11 -3
- package/src/config/bundled-skills/followups/icon.svg +24 -0
- package/src/config/bundled-skills/messaging/SKILL.md +7 -3
- package/src/config/bundled-skills/public-ingress/SKILL.md +183 -0
- package/src/config/bundled-skills/self-upgrade/SKILL.md +4 -10
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +4 -7
- package/src/config/system-prompt.ts +64 -360
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -1
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +5 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +2 -1
- package/src/daemon/handlers/config.ts +20 -9
- package/src/daemon/handlers/home-base.ts +3 -2
- package/src/daemon/handlers/identity.ts +127 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/workspace-files.ts +75 -0
- package/src/daemon/ipc-contract-inventory.json +16 -4
- package/src/daemon/ipc-contract.ts +62 -2
- package/src/daemon/lifecycle.ts +16 -0
- package/src/daemon/session-notifiers.ts +29 -0
- package/src/daemon/session-surfaces.ts +5 -2
- package/src/daemon/session-tool-setup.ts +15 -4
- package/src/home-base/bootstrap.ts +3 -1
- package/src/home-base/prebuilt/seed.ts +16 -5
- package/src/inbound/public-ingress-urls.ts +15 -4
- package/src/runtime/http-server.ts +123 -20
- package/src/security/oauth2.ts +19 -161
- package/src/tools/browser/auto-navigate.ts +2 -2
- package/src/tools/browser/x-auto-navigate.ts +1 -1
- package/src/tools/claude-code/claude-code.ts +1 -1
- package/src/tools/system/version.ts +43 -0
- package/src/tools/tasks/work-item-run.ts +1 -1
- package/src/tools/terminal/parser.ts +29 -7
- package/src/tools/tool-manifest.ts +2 -0
- 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
|
-
|
|
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
|
-
//
|
|
24
|
-
//
|
|
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 (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
package/src/cli/core-commands.ts
CHANGED
|
@@ -555,15 +555,23 @@ export function registerDoctorCommand(program: Command): void {
|
|
|
555
555
|
|
|
556
556
|
// 11. WASM files
|
|
557
557
|
const wasmFiles = [
|
|
558
|
-
'
|
|
559
|
-
'
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
|
18
|
+
## Step 2: Install the latest vellum
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
bun
|
|
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
|
|
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
|
package/src/config/defaults.ts
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -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) {
|