关于 vapi-bootstrap-framework
这是一项实验性技能,可根据 `ROUGH_DRAFT.md` 搭建 Vapi 语音 Agent 项目,生成场景提示词和助手初始化入口。它仅用于明确要求的落地页 Agent 工作流,并非 Vapi 助手的标准开发方式。
主要功能
- 01搭建 Bun 和 TypeScript 项目文件
- 02从草稿提取场景与提示词
- 03按场景和语言构建助手配置
- 04生成幂等的助手初始化脚本
- 05默认使用英语和西班牙语
适用场景
- 01启动实验性的 Vapi 落地页 Agent 项目
- 02为多个通话场景生成助手
- 03根据项目简述初始化多语言版本
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ vapiai/ skills --skill vapi-bootstrap-framework安装后在 Claude Code 中输入/vapi-bootstrap-framework确认已加载。
详细介绍
Vapi 引导框架
实验性参考工作流: 此技能复现了 Vapi 落地页 Agent 所用的架构。它不是构建 Vapi 助手的标准工作流,仅应在明确提出要求时使用。
根据单个 ROUGH_DRAFT.md 规格搭建完整的 Vapi 语音 Agent 项目。可在空文件夹中使用,也可与现有的 Bun + TypeScript 项目配合使用。此技能完成后,只需运行一次 bun run bootstrap,即可让整套 Agent 在 dashboard.vapi.ai 上线。
此技能会生成什么
项目脚手架(仅在文件不存在时创建,不会覆盖):
package.json— Bun +@vapi-ai/server-sdk+bootstrap脚本tsconfig.json— 严格的无输出 TypeScript 校验.env.example—VAPI_API_KEY占位符,以及每个(scenario × language)元组对应的一个配置项.gitignore—node_modules、.env*.local、操作系统杂项文件
框架主干(始终创建 — 属于架构基础;后续所有更改都会扩展其中的插槽,不会修改主干本身):
src/assistants/languages.ts— 各语言的语音 + 转录器配置src/assistants/loadPrompt.ts—loadPrompt(scenarioId, languageId)合成器src/assistants/scenarios/index.ts— 场景注册表src/assistants/buildAssistant.ts— 为任意(scenarioId, languageId)元组合成完整的 Vapi 助手主体src/assistants/prompts/shared/preambles/es.md— 西班牙语语言前置提示词(之后可扩展更多语言)
内容(始终创建 — 为草稿中识别出的每个场景各生成一套):
src/assistants/scenarios/<scenarioId>.ts— ID、名称,以及按语言设置的首条消息src/assistants/prompts/<scenarioId>/body.md— 粗略的首版系统提示词src/assistants/prompts/<scenarioId>/off-topic-es.md— 西班牙语引导语
入口点(始终创建):
src/bootstrap.ts— 遍历场景 × 语言的幂等双重循环
默认设置:语言为 en + es。模型为 openai gpt-4.1,温度为 0.5。语音使用 ElevenLabs eleven_turbo_v2(EN)/ eleven_multilingual_v2(ES)。转录器使用 Deepgram nova-3(EN)/ Soniox stt-rt-v4(ES)。仅当用户明确提出要求时才覆盖这些设置。
工作流
- 检查项目根目录中是否有
ROUGH_DRAFT.md。- 如果存在 → 继续执行第 2 步。
- 如果不存在 → 告知用户,此技能需要一份粗略草稿才能继续。提供下方“ROUGH_DRAFT.md 模板”中的模板;可以替用户粘贴模板,也可以等用户自行提供。在没有草稿之前,不要继续执行此步骤之后的操作。
- 检测场景 — 每个
## N. <scenario name>标题代表一个场景。根据名称生成 snake_case 格式的scenarioId(例如,“Lead Qualification & Screening” →qualification;“Appointment Scheduling” →appointment)。不确定时,选择最短且含义明确的名词。尽量缩短 ID——它们将用作环境变量名。 - 提取开场白 — 在每个场景下,查找
**On the page**、**Opening**、**Greeting**,或该部分中的第一段引号内容。这就是firstMessage.en,须逐字保留。 - 提炼流程 —
**What happens**(或含义相当的说明文字)将作为初步草稿版body.md。以角色设定为主,不是契约——暂不添加失败规则、精确措辞或脚本化的退出话术。保持简短。 - 搭建项目根目录 — 对于
package.json、tsconfig.json、.env.example、.gitignore:如果文件不存在,则根据下方模板创建。如果.env.example已存在,则追加缺失的VAPI_ASSISTANT_<SCENARIO>_<LANG>项;绝不要重排或删除现有行。如果package.json已存在,则保持原样,但要确认依赖项中包含@vapi-ai/server-sdk,且脚本中包含bootstrap和typecheck——如果缺少任何一项,告知用户,不要编辑文件。 - 生成框架骨架 — 从下方模板逐字复制五个骨架文件。它们在不同项目间保持不变;只有场景注册表中的导入内容会变化。
- 生成每个场景的文件 — 针对每个检测到的场景,创建一个
scenarios/<id>.ts、一个prompts/<id>/body.md和一个prompts/<id>/off-topic-es.md。 - 接入注册表 —
scenarios/index.ts导入每个场景并导出SCENARIOS常量、ScenarioId、SCENARIO_IDS、scenarioFor。 - 使用下方的双循环模板编写
src/bootstrap.ts。 - 将开场白翻译成西班牙语 — 使用自然的拉丁美洲西班牙语,不翻译品牌名称。
- 告知用户如何运行(见下方“验证”)。
在此技能中,将每个场景的 clientTools 数组保持为空——功能性信息采集工具将在后续步骤中添加。此处也不要将 body.md 改写成契约——那是单独的步骤。
文件模板
模板中使用以下占位符,需按项目替换:
<PROJECT_NAME>— 根据草稿标题生成的 slug(# Rough draft — <X>→ 将 X 转换为 snake_case/kebab-case)。默认值:vapi-voice-agents。<SCENARIO_ID>— snake_case 场景 ID(例如qualification)<SCENARIO_NAME>— 易读的名称(例如Lead Qualification)<FIRST_MESSAGE_EN>— 草稿中的开场白,逐字保留<FIRST_MESSAGE_ES>— 自然的西班牙语译文<BODY_DRAFT>— 提炼后的初步草稿提示词<SCENARIO_IMPORTS>— 每个场景一条import { <id> } from "./<id>.ts";,按字母顺序排列<SCENARIO_KEYS>—SCENARIOS = { ... }中以逗号分隔的场景 ID<ENV_ASSISTANT_SLOTS>— 每个(场景 × 语言)一条注释行:# VAPI_ASSISTANT_<SCENARIO>_<LANG>=
package.json(仅在文件不存在时)
{
"name": "<PROJECT_NAME>",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"bootstrap": "bun run src/bootstrap.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@vapi-ai/server-sdk": "^1.2.0"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"typescript": "^5.9.3"
},
"packageManager": "[email protected]"
}
tsconfig.json(仅在文件不存在时)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"types": ["bun"]
},
"include": ["src/**/*.ts"]
}
.env.example(不存在时创建;已存在时扩展)
# Get this from https://dashboard.vapi.ai/keys
VAPI_API_KEY=
# One slot per (scenario × language). First `bun run bootstrap` prints the
# ids; paste them here, then re-run for idempotent updates.
<ENV_ASSISTANT_SLOTS>
.gitignore(仅在文件不存在时)
# dependencies
node_modules
# env (local-only secrets)
.env*.local
# os junk
.DS_Store
# bun
*.tsbuildinfo
src/assistants/languages.ts
/**
* Per-language voice + transcriber stack. Adding a 3rd language is one
* entry in each record below.
*/
import type { LanguageId } from "./loadPrompt.ts";
export type { LanguageId };
interface VoiceConfig {
provider: "11labs";
model: string;
voiceId: string;
}
interface TranscriberConfig {
provider: "deepgram" | "soniox";
model: string;
language: string;
}
const VOICE_BY_LANGUAGE: Record<LanguageId, VoiceConfig> = {
en: {
provider: "11labs",
model: "eleven_turbo_v2",
voiceId: "ZoiZ8fuDWInAcwPXaVeq",
},
es: {
provider: "11labs",
model: "eleven_multilingual_v2",
voiceId: "JYyJjNPfmNJdaby8LdZs",
},
};
const TRANSCRIBER_BY_LANGUAGE: Record<LanguageId, TranscriberConfig> = {
en: { provider: "deepgram", model: "nova-3", language: "en" },
es: { provider: "soniox", model: "stt-rt-v4", language: "es" },
};
export const voiceFor = (languageId: LanguageId): VoiceConfig =>
VOICE_BY_LANGUAGE[languageId];
export const transcriberFor = (languageId: LanguageId): TranscriberConfig =>
TRANSCRIBER_BY_LANGUAGE[languageId];
src/assistants/loadPrompt.ts
/**
* EN returns body.md unchanged. ES prepends a Spanish preamble with the
* scenario's off-topic redirects spliced into {{OFF_TOPIC_LINES}}.
* One body.md per scenario drives every language variant.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
export type LanguageId = "en" | "es";
const PROMPT_DIR = resolve(import.meta.dir, "prompts");
const read = (relativePath: string): string =>
readFileSync(resolve(PROMPT_DIR, relativePath), "utf8");
export const loadPrompt = (
scenarioId: string,
languageId: LanguageId,
): string => {
const body = read(`${scenarioId}/body.md`);
if (languageId === "en") return body;
const offTopic = read(`${scenarioId}/off-topic-${languageId}.md`).trim();
const preamble = read(`shared/preambles/${languageId}.md`).replace(
"{{OFF_TOPIC_LINES}}",
offTopic,
);
return `${preamble}\n\n${body}`;
};
src/assistants/scenarios/index.ts
/**
* Scenario registry. Adding a new scenario is one entry here plus one new
* file under `./<scenario-id>.ts` and matching prompts under `../prompts/<id>/`.
*/
// <SCENARIO_IMPORTS>
export const SCENARIOS = {
// <SCENARIO_KEYS>
} as const;
export type ScenarioId = keyof typeof SCENARIOS;
export const SCENARIO_IDS = Object.keys(SCENARIOS) as ScenarioId[];
export const scenarioFor = (id: ScenarioId) => SCENARIOS[id];
src/assistants/buildAssistant.ts
/**
* Compose a full Vapi assistant body for a (scenario, language) tuple.
* Voice + transcriber come from languages.ts; prompt from loadPrompt;
* name + firstMessage + clientTools from the scenario.
*/
import { transcriberFor, voiceFor } from "./languages.ts";
import { loadPrompt, type LanguageId } from "./loadPrompt.ts";
import { scenarioFor, type ScenarioId } from "./scenarios/index.ts";
export type { LanguageId, ScenarioId };
export const buildAssistant = (
scenarioId: ScenarioId,
languageId: LanguageId,
) => {
const scenario = scenarioFor(scenarioId);
const systemPrompt = loadPrompt(scenarioId, languageId);
return {
name: `${languageId.toUpperCase()} - ${scenario.name}`,
firstMessage: scenario.firstMessage[languageId],
voice: voiceFor(languageId),
transcriber: transcriberFor(languageId),
model: {
provider: "openai" as const,
model: "gpt-4.1",
temperature: 0.5,
messages: [{ role: "system" as const, content: systemPrompt }],
tools: scenario.clientTools,
},
};
};
src/assistants/scenarios/<SCENARIO_ID>.ts
/**
* <SCENARIO_NAME> scenario. Plain data: id, name, language-keyed first
* message. Later steps add `clientTools` (capture tools fire mid-call).
*/
// Rename `scenarioId` to the generated <SCENARIO_ID> identifier.
export const scenarioId = {
id: "<SCENARIO_ID>" as const,
name: "<SCENARIO_NAME>",
firstMessage: {
en: "<FIRST_MESSAGE_EN>",
es: "<FIRST_MESSAGE_ES>",
},
clientTools: [] as const,
};
// Rename this type to the generated <PascalCase scenario id>Scenario name.
export type ScenarioIdScenario = typeof scenarioId;
如果 <FIRST_MESSAGE_EN> 较长,为便于阅读,可将其拆分为多个拼接的字符串片段(每行一个完整分句,并使用 " + " 连接)。
src/assistants/prompts/<SCENARIO_ID>/body.md
粗略的初稿——由角色设定驱动,不是契约。建议结构:
# <SCENARIO_NAME> voice agent
You are the <SCENARIO_NAME> voice agent for Vapi. <One sentence on context — who you're talking to and why.>
<One paragraph distilling **What happens** from the rough draft: the questions to ask, the data to collect, the routing logic, the wrap-up.>
Be warm and curious. Ask one question at a time. If they go off-topic, redirect briefly and return to the next missing field. Keep replies short — you are speaking, not typing.
不要加入 ## Absolute rules、失败规则或预设的退出路径。这些是后续步骤。
src/assistants/prompts/<SCENARIO_ID>/off-topic-es.md
两行简短的西班牙语引导语,针对该场景:
- "Buena pregunta — el equipo te puede ayudar con eso. ¿Podemos seguir con <next field>?"
- "Tomo nota, lo vemos después. Mientras tanto, cuéntame <one short ask tied to the scenario>."
src/assistants/prompts/shared/preambles/es.md
# IDIOMA / LANGUAGE OVERRIDE
The contract that follows this preamble is written in English. **This call is in Spanish.**
Override the language rule of the contract:
- ALL agent speech MUST be in natural, conversational Latin American Spanish. Translate the exact wordings, examples, and acks from the contract — don't switch back to English mid-sentence.
- Read brand and product names in their original form. Don't translate them.
- When you call any capture tool, **always pass free-text fields as a short English summary**, regardless of the call language. Cross-language analytics depend on it.
## Off-topic redirects (use verbatim)
If the visitor goes off-topic, pick one of these and then return to the next missing field:
{{OFF_TOPIC_LINES}}
---
src/bootstrap.ts
/**
* Idempotent upsert across (scenario × language). One entry per tuple, keyed
* by VAPI_ASSISTANT_<SCENARIO>_<LANG>. First run creates + prints ids;
* subsequent runs update in place.
*
* Run with `bun run bootstrap`. Bun auto-loads .env.local.
*/
import { VapiClient } from "@vapi-ai/server-sdk";
import {
buildAssistant,
type LanguageId,
type ScenarioId,
} from "./assistants/buildAssistant.ts";
import { SCENARIO_IDS } from "./assistants/scenarios/index.ts";
const LANGUAGES: LanguageId[] = ["en", "es"];
const envVarFor = (scenarioId: ScenarioId, languageId: LanguageId): string =>
`VAPI_ASSISTANT_${scenarioId.toUpperCase()}_${languageId.toUpperCase()}`;
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) {
console.error(`✗ Missing env var: ${name}. See .env.example.`);
process.exit(1);
}
return value;
};
const main = async () => {
const vapi = new VapiClient({ token: requireEnv("VAPI_API_KEY") });
const created: Array<{ envVar: string; id: string }> = [];
for (const scenarioId of SCENARIO_IDS) {
for (const languageId of LANGUAGES) {
const envVar = envVarFor(scenarioId, languageId);
const existingId = process.env[envVar];
const body = buildAssistant(
scenarioId,
languageId,
) as unknown as Parameters<typeof vapi.assistants.create>[0];
const label = `${scenarioId}/${languageId}`;
let updated = false;
if (existingId) {
try {
await vapi.assistants.update({
id: existingId,
...body,
} as unknown as Parameters<typeof vapi.assistants.update>[0]);
console.log(`✓ Updated ${label} → ${existingId}`);
updated = true;
} catch (err) {
const statusCode = (err as { statusCode?: number })?.statusCode;
if (statusCode === 404) {
console.log(
` ${envVar}=${existingId} not found in this org; creating a new assistant.`,
);
} else {
throw err;
}
}
}
if (!updated) {
const assistant = await vapi.assistants.create(body);
console.log(`✓ Created ${label} → ${assistant.id}`);
created.push({ envVar, id: assistant.id });
}
}
}
if (created.length > 0) {
console.log("\nAdd these to .env.local:");
for (const { envVar, id } of created) {
console.log(` ${envVar}=${id}`);
}
console.log(
"\nThen re-run `bun run bootstrap` to confirm idempotent updates.",
);
} else {
console.log(
`\nAll ${SCENARIO_IDS.length * LANGUAGES.length} assistants updated in place.`,
);
}
};
main().catch((err) => {
console.error("✗ Bootstrap failed:", err);
process.exit(1);
});
ROUGH_DRAFT.md 模板
如果用户还没有粗略初稿,请提供这个框架(他们填写尖括号中的内容,技能会据此搭建项目):
# Rough draft — <project name>
## 1. <First scenario name>
**On the page**: "<one verbatim opening line the agent will say>"
**What happens**: <One paragraph: the questions the agent asks, the data it collects, what it does with edge cases, how it wraps up.>
## 2. <Second scenario name>
**Opening**: "<one verbatim opening line>"
**What happens**: <One paragraph.>
一个场景可以,三个场景也可以。技能会根据粗略初稿中的内容调整引导流程。
调整默认值
如果用户要求使用不同的默认值,只修改对应的部分,不要改动周围的结构:
- 使用不同的语言:在
VOICE_BY_LANGUAGE和TRANSCRIBER_BY_LANGUAGE中添加条目,在loadPrompt.ts中扩展LanguageId联合类型,添加prompts/shared/preambles/<lang>.md,为每个场景添加firstMessage.<lang>,将该语言添加到bootstrap.ts中的LANGUAGES,并在.env.example中添加对应的VAPI_ASSISTANT_<SCENARIO>_<LANG>配置项。 - 使用不同的模型 / 温度:只修改
buildAssistant.ts。 - 使用不同的语音服务提供方:只修改
languages.ts中的VoiceConfig和VOICE_BY_LANGUAGE。
验证
生成文件后,告诉用户运行:
bun install
bun run typecheck # validates locally; creates nothing in Vapi
cp .env.example .env.local # add VAPI_API_KEY from dashboard.vapi.ai/org/api-keys
bun run bootstrap # creates one assistant per (scenario × language)
# paste the printed VAPI_ASSISTANT_<SCENARIO>_<LANG>=<id> lines into .env.local
bun run bootstrap # second run prints "Updated ..." for every tuple
对于 N 个场景 × 2 种语言,用户应能在 dashboard.vapi.ai 中看到 N × 2 个助手。每个助手都有一个绿色的“与助手通话”按钮——Agent 会带着用户走完整个流程,但目前仍较为粗略(尚无失败规则,也没有采集工具)。这是预期情况;后续的提示词会进一步完善它们。