Build a LiveKit voice agent with Scalekit AgentKit tools
Give a LiveKit voice agent secure access to Google Calendar and 400+ AgentKit connectors — no token ever reaches the browser or the LLM.
A voice agent that answers “what’s on my calendar?” needs a Google OAuth token scoped to that specific user. LiveKit handles the realtime voice pipeline — speech-to-text, the LLM turn, text-to-speech, turn detection — but it has no built-in concept of per-user third-party credentials. Wire that up yourself and you’re building a token vault, a refresh cycle, and a way to keep both out of reach of the browser and the LLM, before you’ve written a single line of agent logic.
Scalekit AgentKit removes that layer. It stores one OAuth session per connector per user and exposes a single executeTool call that runs any of 20,000+ connector tools on that user’s behalf. This cookbook connects a LiveKit Node.js agent to AgentKit directly — no MCP server, no separate auth service — and shows the one non-obvious part: getting the identity of the person talking to the agent from the browser to the worker process without ever exposing a credential.
What you are building
Section titled “What you are building”- A Next.js route that dispatches a LiveKit agent into a room and mints a browser access token, carrying a Scalekit connection identifier through LiveKit’s dispatch
metadata— the only channel between the two. - A standalone LiveKit agent worker (a separate Node process, not a Next.js route) that reads that identifier from
ctx.job.metadataand callsscalekit.actions.executeTool()directly when the LLM decides to check the calendar. - A pattern that generalizes: swap
googlecalendar_list_eventsfor any AgentKit tool name and the identity-passing mechanism stays identical.
The complete, working source (including the Next.js UI, both API routes, and the agent) is in livekit-scalekit-voice-agent.
Prerequisites
Section titled “Prerequisites”- A LiveKit Cloud project — copy
LIVEKIT_URL,LIVEKIT_API_KEY,LIVEKIT_API_SECRETfrom Settings → Keys. NolkCLI login is required; the app and the agent both read these three values from the environment. - A Scalekit account with AgentKit enabled and a
googlecalendarconnection that shows as Active for the identifier you’ll test with. See Configure a connection. - Node.js 20.11+ — the agent worker uses
import.meta.filename, which older Node versions don’t have.
-
Install dependencies
Section titled “Install dependencies”This is a two-process app: a Next.js frontend/API layer, and a standalone agent worker that runs as a separate
tsxprocess.Terminal npm install @scalekit-sdk/node livekit-server-sdk livekit-client @livekit/components-react @livekit/agents zod next react react-domnpm install -D tsx typescript@livekit/agentsis the Node Agents SDK — it ships thevoice.AgentSessionpipeline, thedefineAgent/cli.runAppworker entrypoint, and thellm.tool()helper for function tools.@scalekit-sdk/nodeis the same Scalekit client either side of this app uses to call AgentKit. -
Set environment variables
Section titled “Set environment variables”Create
.env.localat the project root:.env.local # Scalekit — Settings → API Credentials in your Scalekit dashboardSCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.comSCALEKIT_CLIENT_ID=skc_your_client_idSCALEKIT_CLIENT_SECRET=your_client_secret# Development only: the identifier getSignedInUser() returns until you connect your authTEST_IDENTIFIER=user@example.com# LiveKit Cloud — Settings → KeysLIVEKIT_URL=wss://your-project.livekit.cloudLIVEKIT_API_KEY=your_api_keyLIVEKIT_API_SECRET=your_api_secretTEST_IDENTIFIERstands in for whatever your app uses to identify a signed-in user. It’s only used in development. In production the identifier comes from the authenticated session — see Production notes. -
Carry the identifier through LiveKit dispatch metadata
Section titled “Carry the identifier through LiveKit dispatch metadata”The Next.js route that starts a call is the only place that decides which Scalekit identity the agent runs as. It takes the identifier from the signed-in user on the server, never from the request body, and sends it — not a token — inside LiveKit’s own
metadatafield on the agent dispatch.First, a stand-in for your app’s session lookup. Replace its body with however your app reads the signed-in user from a request:
lib/auth.ts export async function getSignedInUser(_req: Request): Promise<{ identifier: string } | null> {// Replace this with your app's session lookup, and return null when no one is signed in.if (process.env.NODE_ENV !== 'production' && process.env.TEST_IDENTIFIER) {return { identifier: process.env.TEST_IDENTIFIER };}throw new Error('getSignedInUser is not connected to your auth yet');}Then the route:
app/api/livekit/start/route.ts import { randomUUID } from 'node:crypto';import { NextResponse } from 'next/server';import { AccessToken, AgentDispatchClient } from 'livekit-server-sdk';import { getSignedInUser } from '@/lib/auth';const AGENT_NAME = 'scalekit-voice-agent';export async function POST(req: Request) {const user = await getSignedInUser(req);if (!user) {return NextResponse.json({ error: 'Not signed in' }, { status: 401 });}const livekitUrl = process.env.LIVEKIT_URL!;const apiKey = process.env.LIVEKIT_API_KEY!;const apiSecret = process.env.LIVEKIT_API_SECRET!;const identifier = user.identifier;const roomName = `voice-${randomUUID()}`;const metadata = JSON.stringify({ scalekitConnectionId: identifier });const dispatchClient = new AgentDispatchClient(livekitUrl, apiKey, apiSecret);await dispatchClient.createDispatch(roomName, AGENT_NAME, { metadata });const at = new AccessToken(apiKey, apiSecret, { identity: `user-${randomUUID()}` });at.addGrant({ roomJoin: true, room: roomName });const token = await at.toJwt();return NextResponse.json({ roomName, token, url: livekitUrl });}createDispatch(roomName, agentName, { metadata })tells LiveKit’s cloud infrastructure to route this room to a worker registered underagentName— you’ll register the same string in the next step.metadatais a plain JSON string; LiveKit stores it and hands it to the worker’s job context untouched. The browser only ever receivestoken(a room-join JWT) androomName— never a Scalekit credential. -
Read the identifier and call the tool directly
Section titled “Read the identifier and call the tool directly”The agent worker is a separate file, run as its own process — not a Next.js route. It parses the same metadata shape the dispatch call sent, then wires a Scalekit-backed function tool:
agent/src/agent.ts import { ScalekitClient } from '@scalekit-sdk/node';import { cli, defineAgent, llm, ServerOptions, voice, type JobContext } from '@livekit/agents';import { z } from 'zod';const scalekit = new ScalekitClient(process.env.SCALEKIT_ENVIRONMENT_URL!,process.env.SCALEKIT_CLIENT_ID!,process.env.SCALEKIT_CLIENT_SECRET!,);const entry = async (ctx: JobContext): Promise<void> => {await ctx.connect();const metadata = JSON.parse(ctx.job.metadata || '{}') as { scalekitConnectionId?: string };const identifier = metadata.scalekitConnectionId;if (!identifier) {// Only the start route sets this. Don't fall back to a default user.throw new Error('Dispatch metadata has no scalekitConnectionId');}console.log(`[scalekit-voice-agent] job=${ctx.job.id} room=${ctx.room.name} identifier=${identifier}`);const googlecalendar_list_events = llm.tool({description: "List events from the user's Google Calendar. Use this when the user asks about their schedule.",parameters: z.object({calendar_id: z.string().optional().describe("Defaults to 'primary'."),}),execute: async ({ calendar_id }) => {const result = await scalekit.actions.executeTool({connector: 'googlecalendar',identifier,toolName: 'googlecalendar_list_events',toolInput: { calendar_id: calendar_id ?? 'primary' },});return result.data ?? result;},});const agent = new voice.Agent({instructions: "You are a helpful voice assistant. Check the user's calendar when asked.",tools: { googlecalendar_list_events },});const session = new voice.AgentSession({llm: 'openai/gpt-4o-mini',stt: 'assemblyai/universal-streaming',tts: 'cartesia/sonic-2',});await session.start({ agent, room: ctx.room });await session.generateReply({ instructions: 'Greet the user and offer to help with their calendar.' });};export default defineAgent({ entry });cli.runApp(new ServerOptions({ agent: import.meta.filename, agentName: 'scalekit-voice-agent' }));Three things worth naming explicitly:
llm.tool()’s object key is the tool’s name — there’s nonamefield inside the call. The LLM sees the tool asgooglecalendar_list_eventsbecause that’s the key in thetools: { googlecalendar_list_events }map.voice.Agentis a plain constructor —new voice.Agent({ instructions, tools }). There’s no static factory method.- The three model strings are LiveKit Inference identifiers, not your own API keys. They route STT/LLM/TTS through LiveKit Cloud’s gateway, billed to your LiveKit project — no separate OpenAI, AssemblyAI, or Cartesia account needed to get this running.
agentName: 'scalekit-voice-agent'must match the stringAgentDispatchClient.createDispatch()used in step 3 exactly. That’s the only thing connecting the two processes — get it wrong and the dispatch succeeds, a room gets created, and the agent simply never joins it. -
Run both processes
Section titled “Run both processes”Terminal 1 — Next.js app npm run devTerminal 2 — agent worker npx tsx watch --env-file=.env.local agent/src/agent.ts dev--env-filematters here: Next.js loads.env.localautomatically, but a plaintsxprocess doesn’t unless you tell it to.
Testing
Section titled “Testing”Confirm the identity actually reaches the agent before wiring up a browser. Hit the dispatch route directly. In development, getSignedInUser() returns TEST_IDENTIFIER:
curl -X POST http://localhost:3000/api/livekit/startThe agent worker’s terminal should log that identifier within a couple of seconds:
[scalekit-voice-agent] job=AJ_giyT9fjSxPg8 room=voice-2eb7cc3d-... identifier=user@example.comIf that line doesn’t appear, the agent never received the dispatch — see Common mistakes below before checking anything else. Once it does, open your frontend, click Start, and ask “what’s on my calendar today?” — the agent should call executeTool, get back real calendar data, and speak a summary.
Common mistakes
Section titled “Common mistakes”Agent never joins the room
agentName in ServerOptions doesn’t match the second argument to createDispatch(). LiveKit’s dispatch matches by exact string — there’s no error, no timeout message, just a room with no agent in it.
Solution: Compare the two strings directly. In the code above, both are 'scalekit-voice-agent'. A trailing space or a casing difference is enough to break this silently.
TypeError: … is not a function around voice.Agent
Older examples (including early drafts of this pattern) show voice.Agent.create({...}). That method doesn’t exist in @livekit/agents — voice.Agent is a plain constructor: new voice.Agent({ instructions, tools }).
Solution: Use new voice.Agent(...), not a static factory.
dev:agent can’t find SCALEKIT_ENVIRONMENT_URL or LIVEKIT_URL
The agent worker is a standalone Node process. Unlike Next.js, it does not load .env.local on its own.
Solution: Run it with tsx watch --env-file=.env.local agent/src/agent.ts dev, not a bare tsx agent/src/agent.ts.
Agent speaks a generic error instead of calendar data
The identifier reaching the agent doesn’t have an Active googlecalendar connection in Scalekit — most often because the identifier getSignedInUser() returned (TEST_IDENTIFIER in development) doesn’t match the identifier you authorized in the dashboard.
Solution: Check AgentKit → Connections in the Scalekit dashboard, or call scalekit.tools.listScopedTools(identifier, { filter: { connectionNames: ['googlecalendar'] } }) from a debug route to confirm which tools that exact identifier can see.
Production notes
Section titled “Production notes”No token ever reaches the browser or the LLM. The Next.js route returns only roomName, url and a room-join JWT scoped to one room — never a Scalekit credential or the identifier. The agent worker is the only process that ever constructs a ScalekitClient, and executeTool()’s result — plain calendar data — is the only thing that reaches the LLM.
Tools run through executeTool(). The LiveKit Node Agents SDK (@livekit/agents 1.4.11) has no MCP toolset, so the agent calls executeTool() directly, as shown above.
Connect getSignedInUser() to your auth before you deploy. TEST_IDENTIFIER is a development convenience, and the stub throws in production until you replace it. Read the identifier from the signed-in user’s session on the server that handles the dispatch request, never from a field the client sends.
Add more tools by changing one string. The connector/toolName pair in executeTool() is the only connector-specific part of this code. Swap googlecalendar_list_events for any of 400+ AgentKit connectors and the identity-passing mechanism is unchanged.
Next steps
Section titled “Next steps”- Configure more connectors — extend beyond Google Calendar to Gmail, Slack, or GitHub.
- Connected accounts — check connection status and revoke access programmatically.
- AgentKit quickstart — connect your first user in under five minutes.
- LiveKit Agents docs — turn detection, interruption handling, and telephony/SIP.
Related resources
Section titled “Related resources”| Topic | Link |
|---|---|
| AgentKit overview | Overview |
| All connectors | Connectors |
| Connected accounts | Manage connected accounts |
| Sample repository | livekit-scalekit-voice-agent |
| LiveKit Agents docs | docs.livekit.io/agents |
API reference
The endpoints this page's code calls, with every field and the SDK method for each:
- POSTExecute a tool