FastRouter + Scalekit AgentKit tool calling
Build a Node.js agent that routes LLM calls through FastRouter and uses Scalekit AgentKit for per-user OAuth tools.
Build an agent that routes LLM calls through FastRouter and executes OAuth-connected tools through Scalekit AgentKit. FastRouter provides an OpenAI-compatible chat completions API, so the integration requires only one configuration change: point the OpenAI SDK’s baseURL at FastRouter. Scalekit handles OAuth token storage, tool discovery, and tool execution for every connected service.
The sample repository is fastrouter-scalekit-demo on GitHub.
What you are building
Section titled “What you are building”- FastRouter as the LLM provider — All chat completions go through FastRouter’s OpenAI-compatible endpoint. Switch models by changing one environment variable.
- Scalekit AgentKit for tool access —
listScopedToolsreturns per-user tool schemas ready to pass directly to FastRouter.executeToolruns each tool server-side and returns structured results. - B2B OAuth without custom OAuth code — Scalekit handles the OAuth flow, token storage, and refresh for each connected service. Your agent gets an auth link, waits for the user to authorize, and receives a verified, active connected account.
- Agentic loop — The agent calls FastRouter, receives tool calls, executes them through Scalekit, and feeds results back — repeating until FastRouter returns a final answer.
Prerequisites
Section titled “Prerequisites”- Scalekit account with AgentKit enabled — create one at app.scalekit.com
- At least one AgentKit connection configured (Gmail, GitHub, or Slack)
- FastRouter account and API key — sign up at fastrouter.ai
- Node.js 20 or later
Clone and run the sample
Section titled “Clone and run the sample”-
Clone the repository and install dependencies.
Terminal window git clone https://github.com/scalekit-developers/fastrouter-scalekit-democd fastrouter-scalekit-demonpm install -
Copy the example environment file and fill in your credentials.
Terminal window cp .env.example .envOpen
.envand set these values:Terminal window # Scalekit — find these in your Scalekit dashboard under API KeysSCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.devSCALEKIT_CLIENT_ID=your_client_idSCALEKIT_CLIENT_SECRET=your_client_secret# The AgentKit connection to use — must match a connection name in your dashboardSCALEKIT_CONNECTION_NAME=gmail# FastRouter — find your API key at fastrouter.ai/dashboardFASTROUTER_API_KEY=sk-v1-...FASTROUTER_MODEL=openai/gpt-4o-miniSCALEKIT_CONNECTION_NAMEmust match the exact connection name in your Scalekit dashboard under AgentKit → Connections. -
Run the agent.
Terminal window npm start -
Authorize the connection on first run.
The agent prints an authorization link if the connected account is not yet active:
Authorization required.Open this link and complete the flow:https://your-env.scalekit.dev/magicLink/...Waiting for callback on http://localhost:3000/callback ...Open the link in your browser and complete the OAuth flow. The agent detects the callback automatically and continues — no manual step required.
After authorization, the agent loads tools, calls FastRouter, and prints a final answer:
Connected account is now active.Loaded 17 scoped tools from Scalekit.Model requested 1 tool call(s).
→ Executing gmail_list_messages args: {"maxResults":5,"q":"is:unread"}
Final answer:
Here are your 5 most recent unread emails: ...How the agent works
Section titled “How the agent works”Three pieces connect FastRouter to Scalekit tools.
B2B OAuth connects user accounts without custom token code
Section titled “B2B OAuth connects user accounts without custom token code”Scalekit handles the full OAuth flow. Your agent calls getOrCreateConnectedAccount to check whether the user’s account is already connected, then calls getAuthorizationLink to get an auth URL if it isn’t.
import { ConnectorStatus } from '@scalekit-sdk/node/lib/pkg/grpc/scalekit/v1/connected_accounts/connected_accounts_pb';import crypto from 'node:crypto';
const userVerifyUrl = 'http://localhost:3000/callback';
// Generate a random state value and store it (e.g. in a secure cookie or session)// to validate on the OAuth callback and prevent CSRF / account mix-up attacks.const state = crypto.randomUUID();
const { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName: 'gmail', identifier: 'user_123', userVerifyUrl,});
if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName: 'gmail', identifier: 'user_123', userVerifyUrl, state, }); // Show link to user, then wait for the browser redirect callback}import secrets
user_verify_url = "http://localhost:3000/callback"
# Generate and store a state value (e.g. in a secure, HTTP-only cookie) for CSRF protectionstate = secrets.token_urlsafe(32)
response = scalekit_client.actions.get_or_create_connected_account( connection_name="gmail", identifier="user_123", user_verify_url=user_verify_url,)
if response.connected_account.status != "ACTIVE": link_resp = scalekit_client.actions.get_authorization_link( connection_name="gmail", identifier="user_123", user_verify_url=user_verify_url, state=state, ) # Show link_resp.link to the userimport ( "context" "crypto/rand" "encoding/hex" "fmt" "log")
userVerifyURL := "http://localhost:3000/callback"
// generate state for CSRF protection (store it for callback validation)b := make([]byte, 16)rand.Read(b)state := hex.EncodeToString(b)
resp, err := scalekitClient.Actions.GetOrCreateConnectedAccount( context.Background(), "gmail", "user_123",)if err != nil { log.Fatal(err)}
if resp.ConnectedAccount.Status != "ACTIVE" { link, _ := scalekitClient.Actions.GetAuthorizationLink( context.Background(), "gmail", "user_123", ) // Reference state + userVerifyURL so Go sees them as used. fmt.Printf("Authorize: %s (state=%s, callback=%s)\n", link.Link, state, userVerifyURL)}import java.util.UUID;
// Generate state for CSRF protection (store for later validation in callback)String state = UUID.randomUUID().toString();
ConnectedAccountResponse response = scalekitClient.actions() .getOrCreateConnectedAccount("gmail", "user_123");
ConnectedAccount account = response.getConnectedAccount();if (!"ACTIVE".equals(account.getStatus())) { AuthorizationLink link = scalekitClient.actions() .getAuthorizationLink("gmail", "user_123"); System.out.println("Authorize: " + link.getLink()); // Pass state and userVerifyUrl here when the Java SDK overload supports it}userVerifyUrl is where Scalekit redirects the user’s browser after the OAuth flow completes (GET request with auth_request_id and state query parameters). The sample runs a minimal HTTP server on localhost:3000 to catch that redirect, validate the state against the original value, extract the auth_request_id, and call verifyConnectedAccountUser to mark the account active:
async function waitForCallback(port: number, expectedState: string): Promise<string> { return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', `http://localhost:${port}`); const authRequestId = url.searchParams.get('auth_request_id'); const returnedState = url.searchParams.get('state');
res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('<html><body><h2>Authorization complete — return to your terminal.</h2></body></html>'); server.close();
if (authRequestId && returnedState === expectedState) { resolve(authRequestId); } else { reject(new Error('Invalid or missing auth_request_id or state in callback')); } }); server.listen(port); });}
const authRequestId = await waitForCallback(3000, state);await scalekit.actions.verifyConnectedAccountUser({ authRequestId, identifier: 'user_123',});# In your web framework callback handler (e.g. FastAPI):# 1. Validate that the "state" query param matches the value you stored earlier# 2. Then exchange the auth_request_id (never trust identity from the URL alone)
result = scalekit_client.actions.verify_connected_account_user( auth_request_id=auth_request_id, identifier="user_123",)# redirect to result.post_user_verify_redirect_url// In your HTTP handler for the callback:// - Read state and auth_request_id from query params// - Validate state against the one you generated and stored// - Then call verify
resp, err := scalekitClient.Actions.VerifyConnectedAccountUser( context.Background(), authRequestID, "user_123",)// In your servlet / controller callback handler:// 1. Validate state query param matches the stored value// 2. Call verify only on success
VerifyConnectedAccountUserResponse resp = scalekitClient.actions() .verifyConnectedAccountUser(authRequestId, "user_123");Tool discovery returns schemas in FastRouter’s expected format
Section titled “Tool discovery returns schemas in FastRouter’s expected format”listScopedTools returns only the tools the connected account has permission to use. Map each tool’s input_schema to the parameters field FastRouter expects:
const { tools } = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: ['gmail'] }, pageSize: 100,});
const fastRouterTools = tools .map((t) => t.tool?.definition) .filter((def): def is NonNullable<typeof def> => Boolean(def?.name)) .map((def) => ({ type: 'function' as const, function: { name: String(def.name), description: String(def.description ?? ''), parameters: def.input_schema ?? { type: 'object', properties: {} }, }, }));FastRouter uses the same function-calling format as OpenAI. No additional schema transformation is needed.
The agentic loop runs until the model stops requesting tools
Section titled “The agentic loop runs until the model stops requesting tools”Pass the tool list to FastRouter and execute each tool call through Scalekit until the model returns a response with no tool calls:
const messages: OpenAI.ChatCompletionMessageParam[] = [ { role: 'system', content: 'You are a helpful assistant. Use tools when they help. Do not invent tool results.' }, { role: 'user', content: 'Fetch my last 5 unread emails and summarize them.' },];
for (let turn = 0; turn < 8; turn++) { const response = await fastRouter.chat.completions.create({ model: 'openai/gpt-4o-mini', messages, tools: fastRouterTools, tool_choice: 'auto', });
const message = response.choices[0].message; messages.push(message);
// No tool calls means a final answer if (!message.tool_calls?.length) { console.log(message.content); return; }
// Execute each tool call and append the result for (const call of message.tool_calls) { const result = await scalekit.actions.executeTool({ toolName: call.function.name, identifier: 'user_123', connector: 'gmail', toolInput: JSON.parse(call.function.arguments), });
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result.data ?? {}), }); }}executeTool runs the tool server-side using the connected account’s stored OAuth tokens. Your agent never handles raw access tokens.
Customize the agent
Section titled “Customize the agent”Change the connection. Set SCALEKIT_CONNECTION_NAME to any connection configured in your Scalekit dashboard:
| Value | What it connects |
|---|---|
gmail | Gmail read/send |
github | Repositories, issues, pull requests |
slack | Channels, messages, users |
Change the model. Set FASTROUTER_MODEL in .env to any model FastRouter supports. The agent uses the same code regardless of which model you choose.
Change the prompt. Pass a prompt as a CLI argument to override the default:
npm start "List all GitHub pull requests assigned to me"Or set USER_PROMPT in .env to change the default.
Support multiple connections. Call listScopedTools with multiple connection names to give the model tools from all of them at once:
const { tools } = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: ['gmail', 'github', 'slack'] },});Next steps
Section titled “Next steps”- Scalekit AgentKit overview — Understand connected accounts, tool discovery, and tool execution in depth.
- AgentKit connections — Set up Gmail, GitHub, Slack, and other connections.
- OpenAI example — See the same tool-calling pattern with OpenAI directly.
- LiteLLM inbox triage cookbook — A more complex multi-connection agent with a web approval interface.