( 15 options: OAuthUserConfig
& { 16 issuer: string 17 organizationId?: string 18 connectionId?: string 19 domain?: string 20 } 21 ): OAuthConfig
{ 22 const { issuer, organizationId, connectionId, domain } = options 23 24 return { 25 id: "scalekit", 26 name: "Scalekit", 27 type: "oidc", 28 issuer, 29 authorization: { 30 params: { 31 scope: "openid email profile", 32 ...(connectionId && { connection_id: connectionId }), 33 ...(organizationId && { organization_id: organizationId }), 34 ...(domain && { domain }), 35 }, 36 }, 37 profile(profile) { 38 return { 39 id: profile.sub, 40 name: profile.name ?? `${profile.given_name} ${profile.family_name}`, 41 email: profile.email, 42 image: profile.picture ?? null, 43 } 44 }, 45 style: { bg: "#6f42c1", text: "#fff" }, 46 options, 47 } 48 } ``` After PR #13392 merges, replace the local import with: ```typescript import Scalekit from "next-auth/providers/scalekit" ``` ### 4. Configure `auth.ts` [Section titled “4. Configure auth.ts”](#4-configure-authts) Create `auth.ts` in your project root: ```typescript 1 import NextAuth from "next-auth" 2 import Scalekit from "./providers/scalekit" // → "next-auth/providers/scalekit" after PR #13392 3 4 export const { handlers, auth, signIn, signOut } = NextAuth({ 5 providers: [ 6 Scalekit({ 7 issuer: process.env.AUTH_SCALEKIT_ISSUER!, 8 clientId: process.env.AUTH_SCALEKIT_ID!, 9 clientSecret: process.env.AUTH_SCALEKIT_SECRET!, 10 // Routing: set one of these (see step 7 for strategy) 11 connectionId: process.env.AUTH_SCALEKIT_CONNECTION_ID, 12 }), 13 ], 14 basePath: "/auth", 15 session: { strategy: "jwt" }, 16 }) ``` `basePath: "/auth"` is required to match the redirect URI you registered in step 1. Without it, Auth.js uses `/api/auth` and the Scalekit callback will fail. ### 5. Set environment variables [Section titled “5. Set environment variables”](#5-set-environment-variables) .env.local ```bash # Generate with: npx auth secret AUTH_SECRET= # From Scalekit dashboard → API Keys AUTH_SCALEKIT_ISSUER=https://yourenv.scalekit.dev AUTH_SCALEKIT_ID=skc_... AUTH_SCALEKIT_SECRET= # Connection ID for development routing (conn_...) # In production, resolve this dynamically per tenant — see step 7 AUTH_SCALEKIT_CONNECTION_ID=conn_... ``` `AUTH_SECRET` is not optional. Auth.js uses it to sign JWTs and encrypt session cookies. Missing it causes sign-in to fail silently. ### 6. Wire up route handlers [Section titled “6. Wire up route handlers”](#6-wire-up-route-handlers) Create `app/auth/[...nextauth]/route.ts`: ```typescript import { handlers } from "@/auth" export const { GET, POST } = handlers ``` This exposes `GET /auth/callback/scalekit` and `POST /auth/signout` — the endpoints Auth.js needs. The directory must be `app/auth/` (not `app/api/auth/`) to match the `basePath` you configured. ### 7. SSO routing strategies [Section titled “7. SSO routing strategies”](#7-sso-routing-strategies) Scalekit resolves which IdP connection to activate using these params (highest to lowest precedence): ```typescript Scalekit({ issuer: process.env.AUTH_SCALEKIT_ISSUER!, clientId: process.env.AUTH_SCALEKIT_ID!, clientSecret: process.env.AUTH_SCALEKIT_SECRET!, // Option A — exact connection (dev / single-tenant use) connectionId: "conn_...", // Option B — org's active connection (multi-tenant: look up org from user's DB record) organizationId: "org_...", // Option C — resolve org from email domain (useful at login prompt) domain: "acme.com", }) ``` In production, don’t hardcode these values. Store `organizationId` or `connectionId` per tenant in your database, then construct the `signIn()` call dynamically based on the authenticated user’s org: ```typescript // Example: look up org at sign-in time const org = await db.organizations.findByDomain(emailDomain) await signIn("scalekit", { organizationId: org.scalekitOrgId, redirectTo: "/dashboard", }) ``` ### 8. Trigger sign-in and read the session [Section titled “8. Trigger sign-in and read the session”](#8-trigger-sign-in-and-read-the-session) A server component reads the session, and a sign-in form triggers the flow: app/page.tsx ```typescript 1 import { auth, signIn } from "@/auth" 2 3 export default async function Home() { 4 const session = await auth() 5 6 if (session) { 7 return ( 8
Signed in as {session.user?.email}
10Click below to authorize access to your ${serviceName} account. 48 The actor will continue automatically once you complete authorization.
49 Authorize ${serviceName} → 50Returning to task — you can close this tab.
62 63 `; 64 } ``` The live view URL follows this pattern: ```text https://{actorId}--{actorRunId}-{PORT}.runs.apify.net ``` Both `actorId` and `actorRunId` come from `Actor.getEnv()` — the same call that gives you `userId`. ### 7. Wire auth into the Actor entry point [Section titled “7. Wire auth into the Actor entry point”](#7-wire-auth-into-the-actor-entry-point) Pass the live view callback into `ensureNotionConnected`. The callback starts the HTTP server, stores the `markDone` function, and returns it so the polling loop can update the page when auth completes. src/main.js ```js 1 import { Actor } from 'apify'; 2 import { ScalekitClient } from '@scalekit-sdk/node'; 3 import { ensureNotionConnected } from './notionAuth.js'; 4 import { serveAuthPage } from './authServer.js'; 5 6 await Actor.init(); 7 8 const input = await Actor.getInput(); 9 const { task } = input; 10 11 const { userId } = Actor.getEnv(); 12 const notionIdentifier = userId; 13 const youtubeIdentifier = 'shared-youtube'; 14 15 const scalekit = new ScalekitClient( 16 process.env.SCALEKIT_ENVIRONMENT_URL, 17 process.env.SCALEKIT_CLIENT_ID, 18 process.env.SCALEKIT_CLIENT_SECRET, 19 ); 20 21 await ensureNotionConnected(scalekit.actions, notionIdentifier, { 22 onMagicLink: async (link) => { 23 const { liveViewUrl, markDone } = await serveAuthPage(link, 'Notion'); 24 25 // Store the live view URL in OUTPUT so the Apify UI shows a clickable link. 26 await Actor.setValue('OUTPUT', { 27 status: 'AWAITING_NOTION_AUTH', 28 authPageUrl: liveViewUrl, 29 message: 'Open authPageUrl in your browser to authorize Notion.', 30 }); 31 32 await Actor.setStatusMessage(`ACTION REQUIRED: Authorize Notion → ${liveViewUrl}`); 33 34 return markDone; 35 }, 36 }); 37 38 // ... run the agent, push results ``` End-user experience after this change: 1. User starts the Actor run and types their task 2. **Output** panel immediately shows a clickable `authPageUrl` 3. User opens the URL and sees a branded “Authorize Notion →” button 4. After completing OAuth, the page updates to ”✅ Notion Authorized” 5. The Actor continues automatically — no re-run needed Apify web view does not auto-refresh The Actor’s live web view in the Apify Console does not refresh automatically. After completing the OAuth flow, the page may still show the “Authorize” button. Click the **auto-refresh** toggle in the web view toolbar, or open the URL in a new tab to see the updated state. The Actor itself continues regardless — it polls the account status server-side and proceeds as soon as authorization completes. ### 8. Design the input schema for end users [Section titled “8. Design the input schema for end users”](#8-design-the-input-schema-for-end-users) The Actor’s input form should show only what the end user actually needs to provide. All auth identifiers, LLM config, and internal settings stay out of the form. .actor/input_schema.json ```json { "title": "Notion + YouTube AI Agent", "type": "object", "schemaVersion": 1, "properties": { "task": { "title": "Task", "type": "string", "description": "Natural language task for the agent. Examples: 'List the 5 most recently edited pages in my Notion workspace' or 'Search YouTube for React tutorial channels and append the top 10 to my Research page'.", "editor": "textarea" } }, "required": ["task"] } ``` By default the Actor uses [Apify’s OpenRouter proxy](https://apify.com/apify/openrouter) for LLM inference, authenticated via `APIFY_TOKEN` (which Apify sets automatically). No external API key is needed — LLM costs are billed to the user’s Apify credits. If your Actor needs to support a custom LLM endpoint, add an optional `llmApiKey` field and detect the endpoint at runtime. Everything else — `notionIdentifier`, `youtubeIdentifier`, timeouts, model name, base URL — is either derived at runtime (`userId`) or hardcoded and deployed as an Actor environment variable. Apify input schema does not support placeholder The Apify input schema spec does not allow a `placeholder` property on fields. Put example values in `description` instead — they appear as helper text below the field label in the Console. ### 9. Testing [Section titled “9. Testing”](#9-testing) Run locally: ```bash apify run ``` Provide input in `storage/key_value_stores/default/INPUT.json`: ```json { "task": "List the 5 most recently edited pages in my Notion workspace" } ``` Because `Actor.getEnv().userId` is `undefined` locally, the `notionIdentifier` falls back to your local development value. After you confirm the flow works, deploy to Apify: ```bash apify push ``` On the first cloud run, the Actor outputs an `authPageUrl`. Open it, click **Authorize Notion**, and complete the OAuth flow. The Actor polls and continues automatically. On every subsequent run for the same Apify account, the token is already active and the auth step is skipped entirely. ## Common mistakes [Section titled “Common mistakes”](#common-mistakes) Tool calls fail with `permission_denied` even though the account is ACTIVE * **Symptom**: `[permission_denied] tool execution failed - forbidden access` in logs. The connected account status is ACTIVE and authorization completed successfully. * **Cause**: The underlying API is not enabled in the cloud provider console. An ACTIVE connected account means the OAuth token exists — it does not mean the API accepts calls. For YouTube, this happens when **YouTube Data API v3** is not enabled in the Google Cloud project. * **Fix**: Go to [Google Cloud Console → APIs & Services → Enabled APIs](https://console.cloud.google.com/apis/dashboard) and enable **YouTube Data API v3**. No code change or re-authorization needed — existing tokens work once the API is enabled. Authorization fails with `Error 403: org_internal` * **Symptom**: Google shows “Access blocked: \[App name] can only be used within its organization” when a user tries to authorize. * **Cause**: The Google account attempting to authorize is not listed as a test user. While the OAuth app is in “Testing” publishing status, only accounts explicitly added as test users can complete the OAuth flow. * **Fix**: Go to [Google Cloud Console → APIs & Services → OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) and add the Google account under **Test users**. No need to change the app’s publishing status or user type — just add the account and retry. Tool calls fail with `permission_denied` after adding scopes * **Symptom**: Same `permission_denied` error. The connection has the right scopes configured, but you added them after the user already authorized. * **Cause**: OAuth tokens carry the scopes that were configured at authorization time. Adding scopes to a connection does not retroactively update existing tokens. * **Fix**: Delete the connected account in the Scalekit dashboard (or via API) and have the user re-authorize. The new token will include the updated scopes. Notion page creation fails with “no parent_page_id/database_id provided” * **Symptom**: The agent finds no page with the requested title and throws `Notion page "X" was not found and cannot be created because no parent_page_id/database_id was provided`. * **Cause**: Notion’s API requires a parent location for every new page. The Actor checks for a default parent in the input, then in environment variables, and throws if neither is set. * **Fix**: Set `NOTION_DEFAULT_PARENT_PAGE_ID` or `NOTION_DEFAULT_DATABASE_ID` as an Actor environment variable. To find a page ID, open the page in Notion, click **Share → Copy link**, and extract the 32-character hex string from the URL. Using email as the identifier * **Symptom**: Input form asks for user email, or the identifier is passed in as an input field * **Cause**: Treating the connected-account identifier as a user-facing concept * **Fix**: Use `Actor.getEnv().userId` as the identifier. It is stable, unique per Apify account, and requires no input from the user. Scalekit does not require an email — it accepts any unique string as an identifier. `placeholder` property causes build failure * **Symptom**: `apify push` fails with `Property schema.properties.task.placeholder is not allowed.` * **Cause**: `placeholder` is not part of the Apify input schema specification * **Fix**: Move example text into the `description` field. It appears as helper text in the Apify Console input form. `userId` is `undefined` locally * **Symptom**: Actor crashes with `Could not determine Apify user ID` during `apify run` * **Cause**: `Actor.getEnv()` does not populate `userId` in local runs * **Fix**: Fall back to a local dev value: `const notionIdentifier = userId ?? 'local-dev-user'` Stale variable name causes `ReferenceError` at runtime * **Symptom**: Actor fails with `notionUserEmail is not defined` even though you removed that field * **Cause**: The variable was renamed in some places but left in others — console logs, OUTPUT payloads, or `runAgent` arguments * **Fix**: Search the entire codebase for the old variable name before deploying. One missed reference fails at runtime, not at build time. Live view URL not available in local runs * **Symptom**: `getLiveViewUrl()` returns a broken URL during `apify run` * **Cause**: `actorId` and `actorRunId` are also `undefined` locally * **Fix**: Guard the live view server behind a check: `if (actorId && actorRunId) { ... }`. Fall back to logging the raw authorization link to the console for local development. ## Production notes [Section titled “Production notes”](#production-notes) **Token persistence across runs** — Scalekit stores the OAuth token server-side keyed by `(connectionName, identifier)`. As long as `userId` is stable (it is), the user only completes the OAuth flow once. Subsequent runs call `getOrCreateConnectedAccount` and get an active account back immediately. **Token refresh** — Scalekit refreshes expired tokens automatically before returning them. You do not need to track expiry or call a refresh endpoint. **Re-authorization** — If a user revokes access in Notion’s settings, `getOrCreateConnectedAccount` returns a non-active account. The Actor generates a new magic link automatically. No code change required — the polling loop handles it the same way as a first-time auth. **Shared connectors** — The `shared-youtube` identifier works because YouTube access is the same for all users (e.g., read-only public data). Any connector where all users share the same OAuth session can use a hardcoded identifier. Private data connectors — Notion, Gmail, GitHub — should always use a per-user identifier. **Input schema changes require a redeploy** — The Apify Console reads the input schema from the deployed build. Changes to `.actor/input_schema.json` only take effect after `apify push`. ## Next steps [Section titled “Next steps”](#next-steps) * **Add more per-user connectors** — The same `ensureConnected` + `onMagicLink` pattern works for any Scalekit connector. Add a `src/githubAuth.js` following the same structure as `notionAuth.js`. * **Use built-in actions** — For connectors with Scalekit built-in tools, replace manual API calls with `scalekit.actions.executeTool`. See [all supported connectors](/agentkit/connectors/). * **Extend the input schema** — Add optional fields like `maxIterations` or `llmModel` with defaults, so power users can tune the Actor without the defaults getting in the way for casual users. * **Review the AgentKit quickstart** — For a broader overview of the connected-accounts model, see the [AgentKit quickstart](/agentkit/quickstart/). --- # DOCUMENT BOUNDARY --- # 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 [Section titled “What you are building”](#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 [Section titled “Prerequisites”](#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 [Section titled “Environment variables”](#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 [Section titled “Variables present in the demo”](#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_ENVIRONMENT_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) | Your app’s opaque ID (not an email) for the test user whose connections the demo uses, such as `demo_user_1` | | `NEXT_PUBLIC_TEST_SCALEKIT_CONNECTION_ID` | Demo only | Yes | The identifier the demo browser sends in Vapi call `metadata`. Anyone can change it, so don’t carry this pattern into production (see below) | | `SCALEKIT_MCP_CONFIG_ID` | For MCP | No | `cfg_...` ID of your Virtual MCP server (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) [Section titled “Running the token helper (demo)”](#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) [Section titled “What changes when you go live (production)”](#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: BearerYou can close this tab and return to the app. The original page will update automatically.
8