> **Building with AI coding agents?** Install the authstack plugin with one command. This equips your agent with accurate Scalekit implementation patterns.
>
> **Recommended**:
> ```bash
> npx @scalekit-inc/cli setup
> ```
>
> Global:
> ```bash
> npm install -g @scalekit-inc/cli
> scalekit setup
> ```
>
> Supports Claude Code, Cursor, GitHub Copilot, Codex + skills for 40+ agents.
> Features: full-stack-auth, agent-auth, mcp-auth, modular-sso, modular-scim.
> [Full setup guide](https://docs.scalekit.com/dev-kit/build-with-ai/)

---

# Build a Vapi voice assistant with Scalekit

Use Vapi + Scalekit Virtual MCP for voice assistants to securely access any tool from large catalogs.
Voice interfaces shine for hands-free, conversational access to data and actions. But giving a voice agent access to real tools (Gmail, Calendar, Slack, GitHub, Drive, CRM, Notion, and [many other connectors](/agentkit/connectors/)) introduces two hard problems: per-user authentication without leaking tokens to the LLM, and managing a large tool surface without overwhelming context windows or exposing everything to every user.

Scalekit lets you:

- Register built-in tools from connections and custom tools directly in the UI or code (see "Registering tools in the Scalekit UI" below).
- Associate them with specific users via connections.
- Use **Virtual MCP** to bundle hundreds or thousands of those registered tools into a single, scoped, per-user MCP endpoint that Vapi can discover dynamically.

This cookbook walks through building a [Vapi](https://vapi.ai) voice assistant that can safely discover and use any tool (from a catalog of hundreds or thousands) using Scalekit's tool registration + Virtual MCP. You can also call specific registered tools directly via Vapi Function tools if you don't need dynamic discovery of many.

The complete source is available in the [vapi-scalekit-voice-demo](https://github.com/scalekit-developers/vapi-scalekit-voice-demo) repository.

## What you are building

- Register and manage individual tools (built-in from connections or custom) directly in the Scalekit UI, and associate them with users/connections.
- A **Vapi voice assistant** that understands natural language requests (e.g. "List my meetings this week", "Find emails from Acme about the renewal", "Summarize the last thread in #eng-updates on Slack", "Show my open GitHub PRs", "Find the latest proposal in Drive and email it to the team").
- **Dynamic tool discovery** via a Scalekit Virtual MCP — the assistant only sees the tools you explicitly scoped for that role (selected from the ones you registered). Vapi discovers these at call start via the MCP protocol.
- **Per-user OAuth without custom code** — Scalekit handles authorization links, token storage, and refresh. Your app passes the user's identifier.
- **Access to any tool** — scoping + per-user tokens keep context small and access least-privilege even when the full catalog is huge.
- A pattern you can extend to other voice platforms or agent frameworks by swapping the client.

## Prerequisites

- A Scalekit account with AgentKit enabled ([create one](https://app.scalekit.com)).
- At least one connection configured (e.g. Google Calendar, Gmail) under **AgentKit → Connections**. See [Configure a connection](/agentkit/connections/).
- A Vapi account and at least one assistant.
- Node.js 18+ and the demo dependencies.
- ngrok (or equivalent) for local webhook exposure during development.
- Familiarity with basic voice agent concepts and OAuth flows.

## Environment variables

The demo is configured via `.env.local` (copy it from `.env.example`).

```bash
cp .env.example .env.local
```

### Variables present in the demo

| Variable                                   | Required | Client (browser) | Purpose |
|--------------------------------------------|----------|------------------|---------|
| `NEXT_PUBLIC_VAPI_PUBLIC_KEY`              | Yes      | Yes              | Initializes the Vapi Web SDK in the browser |
| `NEXT_PUBLIC_VAPI_ASSISTANT_ID`            | Yes      | Yes              | ID of the assistant to start voice calls against |
| `VAPI_PRIVATE_KEY`                         | For API updates | No            | Used server-side only to call Vapi APIs (create/update tools programmatically) |
| `SCALEKIT_ENV_URL`                         | Yes      | No               | Base URL of your Scalekit environment |
| `SCALEKIT_CLIENT_ID`                       | Yes      | No               | Scalekit API credential (public part) |
| `SCALEKIT_CLIENT_SECRET`                   | Yes      | No               | Scalekit secret. Never ship to browser or commit |
| `TEST_IDENTIFIER`                          | Yes      | No (server)      | The user identity (email / your ID) whose connections will be used |
| `NEXT_PUBLIC_TEST_SCALEKIT_CONNECTION_ID`  | Yes      | Yes              | Browser-safe copy of the identifier; passed in Vapi call `metadata` |
| `SCALEKIT_MCP_CONFIG_ID`                   | For MCP  | No               | `cfg_...` ID of your Virtual MCP config (created in Scalekit dashboard) |
| `NEXT_PUBLIC_SCALEKIT_MCP_SERVER_URL`      | For MCP  | Yes (url only)   | The static base MCP server URL. Token is added at runtime via the `Authorization` header |

**MCP-specific vars** (`SCALEKIT_MCP_CONFIG_ID` + `NEXT_PUBLIC_SCALEKIT_MCP_SERVER_URL`) are only needed when using the dynamic discovery MCP tool path (recommended when you want to expose many tools). They are not required for pure Function tool + `executeTool` mode.

### Running the token helper (demo)

After filling the MCP variables, generate a fresh token for Vapi:

```bash
python scripts/generate-mcp-token.py
```

(or `node --env-file=.env.local scripts/generate-mcp-token.js`)

The script outputs the exact `server` object (url + Authorization header) you paste into the Vapi MCP tool form.

### What changes when you go live (production)

In a real application you do **not** rely on a static `.env.local` for user identity or tokens:

- **Identifier comes from runtime context** — After a real user authenticates in your app and authorizes their connections (Gmail etc.), you obtain their `identifier` from your session / database. Never use a hardcoded `TEST_IDENTIFIER`.

- **Tokens are minted on the fly and short-lived** — When the user starts a voice call, your backend calls Scalekit to create a fresh session token for that specific identifier + config (see `app/api/scalekit/mcp-session/route.ts` and the Python script). Tokens typically live for minutes to an hour.

- **MCP server configuration is injected dynamically** — 
  - Demo: you manually copy-paste `server.url` and the `Authorization: Bearer <token>` header into the Vapi dashboard UI.
  - Production: your backend uses the **Vapi API** (authenticated with `VAPI_PRIVATE_KEY`) to create or patch the MCP tool on the assistant with the fresh per-call `server` object (containing the just-minted token). You can do this immediately before calling `vapi.start(...)` or via Vapi's assistant update endpoints. The token never sits in your dashboard.

- **Secrets stay server-side** — `SCALEKIT_CLIENT_SECRET` and `VAPI_PRIVATE_KEY` remain in your production environment variables / secret manager. Only non-sensitive values (`NEXT_PUBLIC_*` public keys, config IDs, base URLs) may be exposed to the client when truly required.

- For non-MCP flows (Function tools), the webhook receives the identifier via Vapi `metadata` and performs minting + `executeTool` internally.

This design means the same Virtual MCP config works for every user—you only swap the short-lived token and the acting identifier at call time.

## Registering tools in the Scalekit UI

You register and associate tools centrally in the Scalekit dashboard (no code required for built-ins):

- **Set up a connection** (AgentKit → Connections): Choose Gmail, Google Calendar, GitHub, etc. Scalekit registers its built-in tools automatically (e.g. `googlecalendar_list_events`). You can browse and search the full catalog under **AgentKit → Tools** or inside the connection page. See the [full list of connectors](/agentkit/connectors/).

- **Authorize for users**: Go to **AgentKit → Connected Accounts** (or during first use), authorize using your app's `identifier` (e.g. `saif.shaik@scalekit.com`). This associates the tools with that user. The connection must show **Active**.

- **Custom tools**: Define additional tools in your code (see [Build custom tools](/agentkit/tools/custom-tools)) using `actions.request` to call any provider API. They can be associated with connections and included in scopes.

Once registered and associated, these tools are available for:

- Direct calls via `executeTool` (e.g. from a Vapi Function tool webhook).
- Inclusion in a **Virtual MCP** config so a single MCP endpoint can surface hundreds or thousands safely.

This is key when you have many tools: you register/maintain them in the UI or code, then use vMCP to selectively expose subsets to your voice assistant without token bloat or over-privileging.

## Architecture overview

```
User (voice)
  │
  ▼
Vapi assistant (MCP tool configured)
  │  (connects with per-user Bearer token)
  ▼
Scalekit Virtual MCP (scoped to role + user)
  │  (only exposes allowed tools)
  ▼
Scalekit AgentKit (token vault + execute)
  │
  ▼
Real connectors (Gmail, Calendar, Slack, GitHub, ... — [many connectors](/agentkit/connectors/))
```

Key differences from a naive "give the LLM every tool" approach:
- Tool surface is defined once in the Virtual MCP config (least privilege).
- Context size stays manageable (scoping reduces tokens).
- Identity is enforced at the token level (no raw OAuth secrets reach the model or Vapi).

## Step 1: Create a scoped Virtual MCP (select from your registered tools) in Scalekit

1. In the Scalekit dashboard go to **AgentKit → MCP Configs → Create Config**.
2. Give it a name (e.g. "voice-personal-assistant").
3. Add connection tool mappings for the connectors you want. See the [full list of connectors](/agentkit/connectors/). Example for calendar + email (expand later with Slack, GitHub, Drive, etc.):

   ```yaml
   connection_tool_mappings:
     - connection_name: googlecalendar
       tools: [googlecalendar_list_events, googlecalendar_create_event]
     - connection_name: gmail
       tools: [gmail_fetch_mails, gmail_send_mail]   # start small!
   ```

4. Save. Copy the **config ID** (e.g. `cfg_...`) and the generated **mcp_server_url**.

> Image: Creating a Virtual MCP in the Scalekit dashboard

The screenshot above shows the Scalekit dashboard flow for creating the scoped Virtual MCP.

This single config definition is reused for every user. You only change the token you mint at runtime. The scoping here is what lets you safely expose many tools without the LLM seeing everything.

## Step 2: Authorize connections for your test user

For the identifier you will use in the demo (e.g. your email):

1. Go to **AgentKit → Connected Accounts**.
2. Authorize the connections you mapped above.
3. Confirm they show **Active**.

If any are inactive, the token mint will fail with a clear error and an auth link.

## Step 3: Wire the Vapi assistant

You have two main options in Vapi, depending on whether you want dynamic discovery of many scoped tools or direct calls to specific ones.

### Option A: MCP tool for dynamic discovery (recommended when you have many tools)

1. In Vapi, create or edit an assistant.
2. Create a new **MCP** tool (not Function).
3. Configure it with the Scalekit Virtual MCP details:

   - **Server URL**: the `mcp_server_url` from Step 1
   - **HTTP Headers** (look for the **Headers**, **Add Header**, or **Custom Headers** / `server.headers` section in the tool form in the Vapi dashboard):
     - Key: `Authorization`
     - Value: `Bearer <fresh-token>`

4. Attach the MCP tool to the assistant.
5. Update the system prompt to tell the model when and how to use tools (example in the demo repo).

> Image: Registering the MCP tool in the Vapi dashboard

The screenshot above illustrates where to configure the server URL and add the Authorization HTTP header in Vapi's MCP tool form.

### Option B: Function tool for specific registered tools

Create a **Function** tool in Vapi pointing to your webhook URL. The tool name must exactly match a tool you have registered or available in Scalekit (e.g. `googlecalendar_list_events`). Your webhook then calls `executeTool` for it.

You now have a voice agent that can discover tools dynamically from the scoped MCP (or call specific ones directly).

## Step 4: Mint per-user tokens at runtime (the demo)

See the dedicated [Environment variables](#environment-variables) section above for the full list and demo vs. production guidance.

The demo (Next.js + Vapi Web SDK) shows the complete loop for testing:

- User clicks "Start Voice Call" and passes `scalekitConnectionId` (the identifier) via Vapi call `metadata`.
- The backend (or the helper script) mints a fresh short-lived session token for that identifier + your Virtual MCP config.
- You supply the token via the `Authorization: Bearer ...` header so Vapi can connect to the scoped MCP endpoint with the correct identity.

For quick local testing use the provided scripts (prominently shown in the demo UI):

```bash
python scripts/generate-mcp-token.py
```

See `scripts/generate-mcp-token.py` (recommended), `scripts/generate-mcp-token.js`, `app/api/scalekit/mcp-session/route.ts`, and the demo's Virtual MCP panel for exact implementation and copyable output.

Key pattern (what the script / route produces):

```json
{
  "server": {
    "url": "https://...scalekit.../mcp/v3/servers/...",
    "headers": {
      "Authorization": "Bearer <fresh-short-lived-token>"
    }
  }
}
```

In the demo you paste the `url` into Vapi's MCP tool **Server URL** field and add the header manually (see Step 3). The UI and scripts make this easy to copy.

## Production note: tokens and config are injected at runtime

In a real application you never manually edit the Vapi dashboard for each user or call:

- Your backend mints the token **on the fly** (using the Scalekit SDK or direct call with management token) exactly when the user initiates the voice session.
- You then use the Vapi API (authenticated with your `VAPI_PRIVATE_KEY`) to dynamically set or override the MCP tool's `server` (url + Authorization header) on the assistant before starting the call.
- The identifier always comes from the logged-in user context rather than a `TEST_IDENTIFIER` env var.

Mint a session token (and build the `mcpConfig`) in your backend before each call.

Node.js and Python match the demo stack. Go and Java are omitted — the Vapi demo and Virtual MCP token helpers are Node/Python only, and the high-level `create_session_token` API is currently documented for the Python SDK.

  ### Node.js

```ts title="mint-token.ts"
// The Node SDK does not expose actions.mcp.create_session_token yet.
// Mint via the management token + REST, as in the demo route.
try {
  // Security: mint a short-lived, per-user token server-side so the credential
  // never reaches the browser, Vapi dashboard, or the LLM.
  const managementToken = await scalekit.getClientAccessToken();
  const base = process.env.SCALEKIT_ENV_URL!.replace(/\/$/, '');
  const tokenRes = await fetch(
    `${base}/api/v1/actions/mcp/configs/${mcpConfigId}/session-tokens`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${managementToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        identifier: userIdentifier,
        expiry: '1h',
      }),
    },
  );
  if (!tokenRes.ok) {
    throw new Error(await tokenRes.text());
  }
  const tokenData = await tokenRes.json();
  const token = tokenData.token;

  const mcpConfig = {
    url: mcpServerUrl,
    headers: { Authorization: `Bearer ${token}` },
  };

  // Pass mcpConfig to Vapi (via API or call start)
} catch (err) {
  console.error('Token mint failed:', err);
}
```

  ### Python

```python title="mint_token.py"
from datetime import timedelta

try:
    # Security: mint a short-lived, per-user token server-side so the credential
    # never reaches the browser, Vapi dashboard, or the LLM.
    token_response = scalekit_client.actions.mcp.create_session_token(
        mcp_config_id=mcp_id,
        identifier=user_identifier,
        expiry=timedelta(hours=1),
    )

    mcp_config = {
        "url": mcp_server_url,
        "headers": {"Authorization": f"Bearer {token_response.token}"},
    }

    # use mcp_config with Vapi
except Exception as e:
    print(f"Token mint failed: {e}")
```

The same Virtual MCP config (the `cfg_...` you created) is reused for everyone. Only the short-lived token and acting identifier change per user / per call. This is exactly what the demo's `/api/scalekit/mcp-session` endpoint and the "In a real app" callout in the demo UI demonstrate.

## Step 5: Test the voice flow

1. Start the demo: `npm run dev` + `ngrok http 3000`.
2. Update Vapi tool Server URL to your ngrok + `/api/vapi/webhook` (for the Function fallback) or point the MCP tool at the Scalekit URL + token.
3. In the browser: click **Start Voice Call**.
4. Speak natural requests. A few examples that work well with common scoped tools:

   **Calendar**
   - "What do I have on my calendar this week?"
   - "Find a 30-minute slot tomorrow afternoon for a sync with Priya"
   - "Add a meeting with the design team on Friday at 2pm"

   **Email (Gmail)**
   - "Find emails from Acme Corp"
   - "Summarize the latest thread with the legal team"
   - "Draft a polite reply to the last message from Sarah"

   **Slack**
   - "Summarize the latest messages in #product channel"
   - "Any mentions of the launch in Slack this morning?"

   **GitHub + Drive + cross-tool**
   - "Show my open pull requests"
   - "Find the Q3 roadmap in Drive and email a summary to the team"

   - "Check my calendar for tomorrow and email the attendees the agenda doc from Drive"

The assistant should use the discovered tools, execute them via Scalekit (with your identity), and speak the results. Mix and match connectors (see [full list](/agentkit/connectors/)) that you've included in your Virtual MCP config.

Watch the terminal for `[Vapi Webhook]` or MCP connection logs.

## Troubleshooting

| Symptom                              | Likely cause & fix |
|--------------------------------------|--------------------|
| Vapi can't discover tools            | Token expired or missing `Authorization: Bearer <token>` header. Regenerate and paste fresh. |
| "No connected account" when minting  | User hasn't authorized the connections in the Virtual MCP. Authorize in Scalekit → Connected Accounts. |
| Agent ignores tools or hallucinates  | System prompt doesn't instruct tool use, or too many tools in scope. Tighten mappings in Virtual MCP config and strengthen prompt. |
| 401 from Scalekit                    | Wrong identifier or connection not active for the config. Verify with `list_mcp_connected_accounts`. |

See the demo repo for more.

## How this gives voice assistants access to any tool

- **Scoping at config time** — the Virtual MCP only advertises the tools you mapped. The LLM never sees the rest of the catalog.
- **Per-user tokens** — even when hundreds or thousands of tools exist, each call only carries the user's authorized subset.
- **Dynamic discovery** — Vapi fetches the current allowed tools at the start of the conversation. No static tool list to maintain.
- **Token cost control** — fewer tools = dramatically smaller context. A huge catalog would be unusable; a small scoped set (e.g. 5–15 tools) is practical.

**Real use case examples that become feasible only with scoping**:
A sales rep might get "Gmail + Calendar + Salesforce + Slack" (8-12 tools). An engineer might get "GitHub + Drive + Slack + Gmail". Both use the *same* underlying catalog, but each voice session only sees what that person is allowed to do.

The same pattern works for any voice or chat platform that supports MCP clients.

## Security & compliance notes

- Raw OAuth tokens never leave Scalekit.
- Every tool call is audited with the acting user's identity.
- You can rotate or revoke access per connection without touching the agent.
- Virtual MCP gives you an explicit allow-list instead of "all tools the user has ever connected".

## Next steps & variations

- Add more connectors (see the [full list](/agentkit/connectors/)) by extending the Virtual MCP mapping.
- Switch voice platforms (replace Vapi with another MCP-capable voice or chat client).
- Add a real user login flow so the identifier comes from your session instead of an env var.
- Expose the same Virtual MCP to web, mobile, and voice clients from one config.
- Combine with Scalekit's user verification for stronger identity assurance.

## References

- [Scalekit Virtual MCP docs](/agentkit/mcp/overview/)
- [Vapi](https://vapi.ai) (see [MCP integration](https://docs.vapi.ai/tools/mcp))
- [AgentKit tool calling](/agentkit/tools/overview/)
- Demo repo + recording linked above.

See the [full demo source](https://github.com/scalekit-developers/vapi-scalekit-voice-demo) and the [Virtual MCP configuration guide](/agentkit/mcp/configure-mcp-server/).


---

## More Scalekit documentation

| Resource | What it contains | When to use it |
|----------|-----------------|----------------|
| [/llms.txt](/llms.txt) | Structured index with routing hints per product area | Start here — find which documentation set covers your topic before loading full content |
| [/llms-full.txt](/llms-full.txt) | Complete documentation for all Scalekit products in one file | Use when you need exhaustive context across multiple products or when the topic spans several areas |
| [sitemap-0.xml](https://docs.scalekit.com/sitemap-0.xml) | Full URL list of every documentation page | Use to discover specific page URLs you can fetch for targeted, page-level answers |
