# Scalekit AgentKit > Every AgentKit guide, in the order the docs teach it: what AgentKit is, the quickstart, how it works, connecting users, calling tools, Virtual MCP servers, frameworks, agent platforms, custom connectors, managing your workspace, going to production and troubleshooting, then the AgentKit cookbooks. Connectors are listed in https://docs.scalekit.com/_llms-txt/agentkit-connectors.txt, the framework guides and SDK setup are in https://docs.scalekit.com/_llms-txt/agentkit-frameworks.txt, and the REST API reference, with the Python and Node.js SDK method for each endpoint and the webhooks, is in https://docs.scalekit.com/_llms-txt/agentkit-reference.txt. ## Pages in this file, in reading order - https://docs.scalekit.com/agentkit/overview.md - https://docs.scalekit.com/agentkit/quickstart.md - https://docs.scalekit.com/agentkit/concepts.md - https://docs.scalekit.com/agentkit/connections.md - https://docs.scalekit.com/agentkit/tools/authorize.md - https://docs.scalekit.com/agentkit/user-verification.md - https://docs.scalekit.com/agentkit/connected-accounts.md - https://docs.scalekit.com/agentkit/account-events.md - https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools.md - https://docs.scalekit.com/agentkit/tools/custom-tools.md - https://docs.scalekit.com/agentkit/mcp/overview.md - https://docs.scalekit.com/agentkit/mcp/configure-mcp-server.md - https://docs.scalekit.com/agentkit/mcp/session-tokens.md - https://docs.scalekit.com/agentkit/mcp/connect-any-client.md - https://docs.scalekit.com/agentkit/examples.md - https://docs.scalekit.com/agentkit/examples/langchain.md - https://docs.scalekit.com/agentkit/examples/google-adk.md - https://docs.scalekit.com/agentkit/examples/openai.md - https://docs.scalekit.com/agentkit/examples/anthropic.md - https://docs.scalekit.com/agentkit/examples/vercel-ai.md - https://docs.scalekit.com/agentkit/examples/mastra.md - https://docs.scalekit.com/agentkit/examples/crewai.md - https://docs.scalekit.com/agentkit/examples/claude-managed-agents.md - https://docs.scalekit.com/cookbooks/set-up-agentkit-with-your-coding-agent.md - https://docs.scalekit.com/agentkit/hermes.md - https://docs.scalekit.com/agentkit/bring-your-own-connector/overview.md - https://docs.scalekit.com/agentkit/bring-your-own-connector/create-connector.md - https://docs.scalekit.com/agentkit/bring-your-own-connector/making-tool-calls.md - https://docs.scalekit.com/agentkit/environments.md - https://docs.scalekit.com/agentkit/api-credentials.md - https://docs.scalekit.com/agentkit/team-members.md - https://docs.scalekit.com/agentkit/billing.md - https://docs.scalekit.com/agentkit/advanced/launch-checklist.md - https://docs.scalekit.com/agentkit/security.md - https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth.md - https://docs.scalekit.com/agentkit/advanced/custom-domain.md - https://docs.scalekit.com/agentkit/encryption-keys.md - https://docs.scalekit.com/agentkit/self-hosted.md - https://docs.scalekit.com/agentkit/troubleshooting.md - https://docs.scalekit.com/agentkit/advanced/migrate-from-composio.md - https://docs.scalekit.com/cookbooks/apify-actor-per-user-oauth.md - https://docs.scalekit.com/cookbooks/build-voice-assistant-1000-tools.md - https://docs.scalekit.com/cookbooks/crewai-agentkit-email-triage.md - https://docs.scalekit.com/cookbooks/daily-briefing-agent.md - https://docs.scalekit.com/cookbooks/fastrouter-agentkit-tool-calling.md - https://docs.scalekit.com/cookbooks/langsmith-tracing-agentkit.md - https://docs.scalekit.com/cookbooks/litellm-agentkit-inbox-triage.md - https://docs.scalekit.com/cookbooks/livekit-agentkit-voice-tool-calling.md - https://docs.scalekit.com/cookbooks/mastra-agentkit.md - https://docs.scalekit.com/cookbooks/render-github-pr-summarizer.md - https://docs.scalekit.com/cookbooks/schedule-meeting-and-draft-email.md --- Source: https://docs.scalekit.com/agentkit/overview.md # What is AgentKit How AgentKit connects your AI agent to each user's Gmail, Slack, GitHub and other apps, what you can build with it, and what Scalekit handles for you. **AgentKit lets your AI agent act in third-party apps on behalf of each of your users.** A user connects their Gmail, Slack, Salesforce or GitHub account once through an authorization link. Scalekit stores and refreshes their tokens, and your agent calls prebuilt tools such as `gmail_send_message` with that user's ID. Your code never handles OAuth tokens. 405 [connectors](https://docs.scalekit.com/agentkit/connectors/) · Python and Node.js SDKs · REST API · Any MCP client New to AgentKit? Start with the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/). Looking for something specific? See [all AgentKit sections](#all-agentkit-sections). ## What you can build - **An inbox assistant** reads, labels and drafts email in each user's Gmail or Outlook. - **A sales copilot** logs calls and updates deals in Salesforce or HubSpot after every meeting. - **An engineering agent** files issues and reviews pull requests in GitHub, Linear or Jira. ## How it works 1. **You set up a connection** (once per app): in the dashboard. 2. **Your user approves access** (once per user): authorization link. 3. **Scalekit stores tokens**: connected account is ACTIVE once any user verification passes. 4. **Your agent calls a tool** (every tool call): identifier + tool name. On every call: Scalekit adds the token, calls the API and returns JSON to your agent. The app (Gmail, Slack, GitHub…) sees an API call with the user's token. Steps 1 and 2 happen once. Step 4 happens on every call. Your code never handles an OAuth token. [How AgentKit works](https://docs.scalekit.com/agentkit/concepts/) covers the full model, every term and every connected-account status. ## Two ways to give your agent tools | | From your code (SDK) | From an MCP client | | --- | --- | --- | | You get | Tool schemas and `execute_tool` in Python or Node.js | A [Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/overview/) URL per user | | Works with | [LangChain](https://docs.scalekit.com/agentkit/examples/langchain/), [Google ADK](https://docs.scalekit.com/agentkit/examples/google-adk/), [OpenAI](https://docs.scalekit.com/agentkit/examples/openai/), [Anthropic](https://docs.scalekit.com/agentkit/examples/anthropic/), [Vercel AI SDK](https://docs.scalekit.com/agentkit/examples/vercel-ai/), [Mastra](https://docs.scalekit.com/agentkit/examples/mastra/), your own loop | [Claude Managed Agents](https://docs.scalekit.com/agentkit/examples/claude-managed-agents/), [CrewAI](https://docs.scalekit.com/agentkit/examples/crewai/), any MCP client | | Pick it when | You build and host the agent | The agent already exists and speaks MCP | ## What Scalekit handles, and what you build | Scalekit handles | You build | | --- | --- | | OAuth flows and consent screens for every connector | Pick which apps to connect, once, in the dashboard | | Encrypted token storage and refresh | Pass a stable, hard-to-guess ID for each user | | Tool schemas, tool execution and the API proxy | [Verify users](https://docs.scalekit.com/agentkit/user-verification/) in production | | Webhooks when an account's status changes | Choose which tools each agent gets | ## Built for production - **Compliance**: SOC 2 Type II and ISO 27001 certified. GDPR and CCPA compliant. [HIPAA](https://www.scalekit.com/legal/hipaa) eligible, with a Business Associate Agreement on Enterprise plans. - **Token vault**: every OAuth token and API key is encrypted with a key unique to your environment before it's stored. That key is wrapped by a master key in Google Cloud KMS, held apart from the database, so a copy of the database alone yields nothing usable. - **Tool call data**: responses pass through to your agent and aren't stored. Tool call logs record the call's metadata, not its content. See [what Scalekit stores](https://docs.scalekit.com/agentkit/security/#what-scalekit-stores). - **Encryption**: AES-256 at rest and TLS 1.3 in transit. [Bring your own key](https://docs.scalekit.com/agentkit/encryption-keys/) from your Google Cloud KMS; revoking it makes the stored data unusable to Scalekit. - **Data residency**: separate [US and EU deployments](https://docs.scalekit.com/agentkit/environments/#choose-a-region) with no shared state. The EU region runs in Frankfurt. - **Self-hosted**: with an enterprise license, run AgentKit in [your own Kubernetes cluster](https://docs.scalekit.com/agentkit/self-hosted/). Once installed, it has no connection to Scalekit's infrastructure. [Security and compliance](https://docs.scalekit.com/agentkit/security/) covers how credentials are stored, what Scalekit keeps, and what your app is responsible for. Audit reports and penetration test summaries are available under NDA from the [Trust Center](https://www.scalekit.com/trust-center). Read the [Data Processing Agreement](https://www.scalekit.com/legal/data-processing-agreement), which lists sub-processors. See [pricing](https://www.scalekit.com/pricing) for plans. ## All AgentKit sections Every part of the AgentKit docs, in the order you build. Each section matches a group in the sidebar. ### Start Learn what AgentKit does and make your first tool call. - [What is AgentKit](https://docs.scalekit.com/agentkit/overview.md) - [Quickstart](https://docs.scalekit.com/agentkit/quickstart.md) - [How AgentKit works](https://docs.scalekit.com/agentkit/concepts.md) ### Connect users Set up an app connection, then have each user authorize it. - [Set up a connection](https://docs.scalekit.com/agentkit/connections.md) - [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize.md) - [Verify users](https://docs.scalekit.com/agentkit/user-verification.md) - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts.md) - [Account events](https://docs.scalekit.com/agentkit/account-events.md) ### Call tools with the SDK Run prebuilt tools, or call any API endpoint as the user. - [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools.md) - [Call any API](https://docs.scalekit.com/agentkit/tools/custom-tools.md) ### Call tools over MCP Give any MCP client a per-user set of tools. - [How Virtual MCP servers work](https://docs.scalekit.com/agentkit/mcp/overview.md) - [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server.md) - [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens.md) - [Connect any MCP client](https://docs.scalekit.com/agentkit/mcp/connect-any-client.md) ### Frameworks Wire AgentKit into your agent framework. - [Choose a framework](https://docs.scalekit.com/agentkit/examples.md) - [LangChain](https://docs.scalekit.com/agentkit/examples/langchain.md) - [Google ADK](https://docs.scalekit.com/agentkit/examples/google-adk.md) - [OpenAI](https://docs.scalekit.com/agentkit/examples/openai.md) - [Anthropic](https://docs.scalekit.com/agentkit/examples/anthropic.md) - [Vercel AI SDK](https://docs.scalekit.com/agentkit/examples/vercel-ai.md) - [Mastra](https://docs.scalekit.com/agentkit/examples/mastra.md) - [CrewAI](https://docs.scalekit.com/agentkit/examples/crewai.md) - [Claude Managed Agents](https://docs.scalekit.com/agentkit/examples/claude-managed-agents.md) ### Agent platforms Use AgentKit from coding agents and agent platforms. - [Coding agents](https://docs.scalekit.com/cookbooks/set-up-agentkit-with-your-coding-agent.md) - [Hermes](https://docs.scalekit.com/agentkit/hermes.md) ### Add your own connector Connect an app or internal API that isn't in the catalog. - [When to add one](https://docs.scalekit.com/agentkit/bring-your-own-connector/overview.md) - [Create a connector](https://docs.scalekit.com/agentkit/bring-your-own-connector/create-connector.md) - [Call your connector](https://docs.scalekit.com/agentkit/bring-your-own-connector/making-tool-calls.md) ### Manage your workspace Environments, API credentials, team members and billing. - [Environments and regions](https://docs.scalekit.com/agentkit/environments.md) - [API credentials](https://docs.scalekit.com/agentkit/api-credentials.md) - [Team members and roles](https://docs.scalekit.com/agentkit/team-members.md) - [Billing](https://docs.scalekit.com/agentkit/billing.md) ### Go to production Check everything before launch, and harden your setup. - [Launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist.md) - [Security and compliance](https://docs.scalekit.com/agentkit/security.md) - [Use your own OAuth app](https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth.md) - [Custom domain](https://docs.scalekit.com/agentkit/advanced/custom-domain.md) - [Encryption keys](https://docs.scalekit.com/agentkit/encryption-keys.md) - [Self-hosted deployment](https://docs.scalekit.com/agentkit/self-hosted.md) ### Help Fix common errors, or move an existing integration over. - [Troubleshooting](https://docs.scalekit.com/agentkit/troubleshooting.md) - [Migrate from Composio](https://docs.scalekit.com/agentkit/advanced/migrate-from-composio.md) - [Cookbooks](https://docs.scalekit.com/cookbooks/?product=agentkit) ### Connectors Every app AgentKit connects to, with its tools. - [Browse connectors](https://docs.scalekit.com/agentkit/connectors.md) ### Reference Every SDK method, REST endpoint and webhook event. - [API reference](https://docs.scalekit.com/agentkit/reference.md) --- Source: https://docs.scalekit.com/agentkit/quickstart.md # Quickstart Make your first AgentKit tool call in Python, Node.js or cURL: install the SDK, add your credentials, and call GitHub as yourself. Connect your own GitHub account and make your first tool call as that user. New Scalekit environments include a GitHub connection, so there's nothing to set up in GitHub. **About 10 minutes.** ## Before you start - A Scalekit account at [app.scalekit.com](https://app.scalekit.com) - Python 3.10+, Node.js 18.14+, or `curl` and [`jq`](https://jqlang.org/) - Your environment URL, client ID and client secret. Open **Developers** > **Settings** > **API Credentials**: copy the **Environment URL** and **Client ID** from **Environment details**, then select **Generate new secret** under **Client secrets**. The secret is shown once, so copy it right away. See [API credentials](https://docs.scalekit.com/agentkit/api-credentials/). - The GitHub connection name from **AgentKit** > **Connections**. New environments call it `github-connect`. 1. ## Install the SDK **Python** ```sh pip install scalekit-sdk-python python-dotenv ``` **Node.js** ```sh npm init -y npm pkg set type=module npm install @scalekit-sdk/node dotenv npm install -D tsx ``` 2. ## Add your credentials Create a `.env` file next to your script. Don't commit it. ```sh title=".env" SCALEKIT_ENVIRONMENT_URL= SCALEKIT_CLIENT_ID= SCALEKIT_CLIENT_SECRET= GITHUB_CONNECTION_NAME=github-connect ``` 3. ## Write the script Save this as `agent.py`, `agent.ts` or `agent.sh`. It connects the user if needed, then calls GitHub as them. **Python** ```python title="agent.py" import os from dotenv import load_dotenv from scalekit import ScalekitClient load_dotenv() scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit_client.actions connection_name = os.environ["GITHUB_CONNECTION_NAME"] user_id = "user_123" # your app's ID for this user: stable and hard to guess # Find this user's connected account, or create it on the first run account = actions.get_or_create_connected_account( connection_name=connection_name, identifier=user_id, ).connected_account if account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=connection_name, identifier=user_id, ).link print(f"Open this link and approve access:\n{link}") input("Press Enter when you're done...") account = actions.get_connected_account( connection_name=connection_name, identifier=user_id, ).connected_account if account.status != "ACTIVE": raise SystemExit(f"The account is {account.status}, not ACTIVE. Run the script again.") # Call GitHub as this user. The tool is read-only. result = actions.execute_tool( tool_name="github_user_get_authenticated", tool_input={}, connected_account_id=account.id, ) print(result.data) ``` **Node.js** ```ts title="agent.ts" import 'dotenv/config'; import { createInterface } from 'node:readline/promises'; import { ConnectorStatus, ScalekitClient } from '@scalekit-sdk/node'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ); const actions = scalekit.actions; const connectionName = process.env.GITHUB_CONNECTION_NAME!; const userId = 'user_123'; // your app's ID for this user: stable and hard to guess // Find this user's connected account, or create it on the first run let { connectedAccount: account } = await actions.getOrCreateConnectedAccount({ connectionName, identifier: userId, }); if (account?.status !== ConnectorStatus.ACTIVE) { const { link } = await actions.getAuthorizationLink({ connectionName, identifier: userId }); console.log(`Open this link and approve access:\n${link}`); const rl = createInterface({ input: process.stdin, output: process.stdout }); await rl.question("Press Enter when you're done..."); rl.close(); ({ connectedAccount: account } = await actions.getConnectedAccount({ connectionName, identifier: userId, })); if (account?.status !== ConnectorStatus.ACTIVE) { throw new Error('The account is not ACTIVE yet. Run the script again.'); } } // Call GitHub as this user. The tool is read-only. const result = await actions.executeTool({ toolName: 'github_user_get_authenticated', toolInput: {}, connectedAccountId: account.id, }); console.log(result.data); ``` **cURL** ```bash title="agent.sh" #!/usr/bin/env bash set -euo pipefail set -a; source .env; set +a USER_ID="user_123" # your app's ID for this user: stable and hard to guess API="$SCALEKIT_ENVIRONMENT_URL/api/v1" # Exchange your API credentials for an access token TOKEN=$(curl -sS "$SCALEKIT_ENVIRONMENT_URL/oauth/token" \ -d grant_type=client_credentials \ -d client_id="$SCALEKIT_CLIENT_ID" \ -d client_secret="$SCALEKIT_CLIENT_SECRET" | jq -r .access_token) account_status() { curl -sS -G "$API/connected_accounts/details" \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "connector=$GITHUB_CONNECTION_NAME" \ --data-urlencode "identifier=$USER_ID" | jq -r '.connected_account.status // "NOT_CREATED"' } if [ "$(account_status)" != "ACTIVE" ]; then # Creates the connected account on the first run, and returns a link LINK=$(curl -sS "$API/connected_accounts/magic_link" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"connector\": \"$GITHUB_CONNECTION_NAME\", \"identifier\": \"$USER_ID\"}" | jq -r .link) printf 'Open this link and approve access:\n%s\n' "$LINK" read -r -p "Press Enter when you're done..." STATUS=$(account_status) if [ "$STATUS" != "ACTIVE" ]; then echo "The account is $STATUS, not ACTIVE. Run the script again." >&2 exit 1 fi fi # Call GitHub as this user. The tool is read-only. curl -sS "$API/execute_tool" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"tool_name\": \"github_user_get_authenticated\", \"connector\": \"$GITHUB_CONNECTION_NAME\", \"identifier\": \"$USER_ID\", \"params\": {}}" | jq .data ``` `user_123` stands in for your app's user ID. [Choose an identifier](https://docs.scalekit.com/agentkit/concepts/#choose-an-identifier) explains what to use in production. 4. ## Run it **Python** ```sh python agent.py ``` **Node.js** ```sh npx tsx agent.ts ``` **cURL** ```sh bash agent.sh ``` The first run prints a link. Open it, approve access on GitHub, then press Enter. You'll see your GitHub profile. Run it again: the account is already `ACTIVE`, so the script skips the link. ## If it doesn't work ### `connection not found for the given key` `GITHUB_CONNECTION_NAME` doesn't match the dashboard exactly. Copy it again from **AgentKit** > **Connections**. ### The account stays `PENDING_AUTH` The approval wasn't finished, or the link expired. Links last five minutes by default. Run the script again for a fresh link. ### `user_verify_url is required when user_verify_mode is b2b` User verification is set to **Custom user verifier**, so the link request needs a verify URL. For this quickstart, use **None** or **Scalekit users only** in **AgentKit** > **Settings** > **User Verification**. To set up the custom user verifier, see [Verify users](https://docs.scalekit.com/agentkit/user-verification/). ### `KeyError: 'SCALEKIT_ENVIRONMENT_URL'` or `TypeError: Invalid URL` The `.env` file wasn't loaded. Run the script from the folder that contains it. ### `401` or `invalid_client` The client ID or secret is wrong, or the secret was regenerated. ## Next - [How AgentKit works](https://docs.scalekit.com/agentkit/concepts/): What you just created, and why. - [Set up a connection](https://docs.scalekit.com/agentkit/connections/): Connect Gmail, Slack or any other app. - [Choose a framework](https://docs.scalekit.com/agentkit/examples/): LangChain, OpenAI, Anthropic and more. Before real users connect accounts, [verify users](https://docs.scalekit.com/agentkit/user-verification/). ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `GET` [Get a connected account's credentials](https://docs.scalekit.com/agentkit/reference/connected-accounts/get-connected-account-credentials.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) --- Source: https://docs.scalekit.com/agentkit/concepts.md # How AgentKit works Connections, connected accounts, identifiers and tools: the AgentKit model, every connected-account status, and the user verification modes. You configure a **connection** once per app. Each user approves access once, which activates their **connected account**. After that, your agent calls **tools** with the user's **identifier**, and Scalekit makes the API call with that user's token. 1. **You set up a connection** (once per app): in the dashboard. 2. **Your user approves access** (once per user): authorization link. 3. **Scalekit stores tokens**: connected account is ACTIVE once any user verification passes. 4. **Your agent calls a tool** (every tool call): identifier + tool name. On every call: Scalekit adds the token, calls the API and returns JSON to your agent. The app (Gmail, Slack, GitHub…) sees an API call with the user's token. Steps 1 and 2 happen once. Step 4 happens on every call. Your code never handles an OAuth token. ## Key terms | Term | What it is | Who creates it | In code | | --- | --- | --- | --- | | Connector | A supported app, such as Gmail, and its library of tools. | Scalekit, or you for a [custom connector](https://docs.scalekit.com/agentkit/bring-your-own-connector/overview/) | `gmail` | | Connection | Your environment's settings for one connector: an OAuth client and scopes, or the fields for API-key sign-in. All your users share it. | You, once, in the dashboard | `connection_name` | | Connected account | One user's link to a connection. It holds their tokens and a status. | Your code creates it. The user activates it by approving access. | `connected_account_id` | | Identifier | Your app's ID for the user. Always pass it with the connection name. | You | `identifier` | | Authorization link | A one-time URL where the user approves access. | Scalekit, when your code asks for one | `get_authorization_link` | | User verification | The check that the person who approved access is the user you meant. | Your server, or Scalekit | `verify_connected_account_user` | | Tool | One action on a connector, with an input schema. | Scalekit for built-in tools, or you for custom tools | `tool_name` | | Virtual MCP server | A URL that exposes chosen tools for one user to any MCP client. | You, per agent | `actions.mcp` | | Session token | A short-lived bearer token that lets an MCP client call a Virtual MCP server's tools as one user. [Mint one](https://docs.scalekit.com/agentkit/mcp/session-tokens/) before each run. | Your server, per run | `actions.mcp.create_session_token` | | API proxy | A call to the app's own API through Scalekit, which adds the user's credentials. Use it when no [built-in tool](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/) fits. See [Call any API](https://docs.scalekit.com/agentkit/tools/custom-tools/). | Your code | `actions.request` | The **In code** column shows Python names. The Node.js SDK uses the camelCase form, such as `connectionName` and `connectedAccountId`. One exception: Node.js `executeTool` takes the connection name as `connector` and the tool's inputs as `toolInput`. The REST API also names the connection `connector`. ## Connected account statuses | Status | Means | What to do | | --- | --- | --- | | `ACTIVE` | Tokens are valid. | Call tools. | | `PENDING_AUTH` | The user hasn't finished approving access. | Send a new authorization link. | | `PENDING_VERIFICATION` | The user approved, but user verification hasn't confirmed them. | Finish your verify step, or check the verification mode. | | `EXPIRED` | Tokens expired or were revoked and couldn't be refreshed. | Send a new authorization link. | | `DISCONNECTED` | The account was disconnected. | Reconnect with a new authorization link. | In Python, `status` is a string such as `"ACTIVE"`. In Node.js, it is the numeric `ConnectorStatus` enum: import it from `@scalekit-sdk/node` (2.18.0 or later) and compare with `ConnectorStatus.ACTIVE`. The REST API returns the name as a string. ## Choose an identifier Use your app's internal user ID. It must be stable (it never changes), unique per user and hard to guess. Don't use an email address: a user can change their email, and an old address can be reassigned to someone else, so the identifier would no longer point to the same person. An email is also easy to guess, which makes misuse easier. Pass the identifier together with the connection name. When you pass `connected_account_id` instead, Scalekit ignores the identifier. ## User verification modes Set the mode in **AgentKit** > **Settings** > **User Verification**. New environments start with **None**. | Mode | What happens | Use it for | | --- | --- | --- | | Custom user verifier | Scalekit redirects the user to your verify URL. Your server confirms the user and calls the verify API. | Production (recommended) | | Scalekit users only | The person approving access must be signed in to your Scalekit dashboard. | Internal testing | | None | Anyone with the link activates the account. | Development only | [Verify users](https://docs.scalekit.com/agentkit/user-verification/) shows how to set up the custom user verifier. ## Next - [Set up a connection](https://docs.scalekit.com/agentkit/connections/): Configure the app your agent needs. - [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/): Send the authorization link and wait until the account is ACTIVE. - [Verify users](https://docs.scalekit.com/agentkit/user-verification/): Set up the custom user verifier before real users connect. --- Source: https://docs.scalekit.com/agentkit/connections.md # Set up a connection Set up a connection in the Scalekit dashboard to authorize your agent to use a third-party connector on behalf of your users. A **connection** is a configuration you create once in the Scalekit dashboard. It holds everything Scalekit needs to interact with a connector's API: OAuth app credentials, scopes, redirect URIs, and so on. One connection serves all your users. Users don't configure connections. Each user gets a **connected account**, the per-user record that links them to a connection and holds their tokens. Your code creates it with `get_or_create_connected_account`, or when it requests an authorization link for the user, and the user activates it by authorizing. ## What the connection form asks for The connection form adapts to what the connector requires. Two things determine how much you need to configure: - **OAuth-based connectors** require the most setup. You register an OAuth app with the provider, then enter those credentials into Scalekit. - **Non-OAuth connectors** (API key, basic auth, key pairs, and similar) require minimal developer setup (usually just a name). The user provides their own credentials when they create their connected account. The sections below walk through both patterns. ## Set up an OAuth connection OAuth connections need an OAuth app registered with the provider. Scalekit provides the Redirect URI; you bring the Client ID and Client Secret. Many connectors also offer Scalekit's own credentials, so you can skip registration entirely (see the tip at the end of this section). **Already see pre-filled credentials? DCR handled registration for you.** For some connectors, Scalekit automatically completes **Dynamic Client Registration (DCR)** with the provider. If the **Client ID**, **Client Secret**, **OAuth Authorization URL**, and **Token Endpoint** fields are already filled in when you open the connection form, DCR was successful — skip steps 2–4 below and go directly to [configuring scopes](#configure-scopes). If the fields are empty, the provider does not support DCR. Follow the manual registration steps below. 1. ### Open the connection form In the Scalekit dashboard, go to **AgentKit** > **Connections** and click **Add connection**. Select the connector you want to configure. The form shows the fields that connector requires. 2. ### Copy the redirect URI Scalekit generates a **Redirect URI** for this connection. Copy it; you'll need it in the next step. This URI is where the provider sends the user after they complete the OAuth consent screen. Scalekit handles the callback automatically. 3. ### Register your OAuth app with the provider In the provider's developer console (GitHub, Salesforce, Google, etc.), create an OAuth app and add Scalekit's Redirect URI to the list of authorized redirect URIs. The provider will give you a **Client ID** and **Client Secret** after registration. > caution: Redirect URI must match exactly > > The URI in the provider's console must match what Scalekit shows character-for-character, including trailing slashes. A mismatch causes the OAuth flow to fail with a `redirect_uri_mismatch` error. 4. ### Enter your credentials Back in the Scalekit dashboard, enter the **Client ID** and **Client Secret** from the provider. 5. ### Configure scopes Select the scopes your agent needs. Scopes define what your agent can do on the user's behalf: for example, `user:email` or `repo` for GitHub. > note: Scopes apply to all connected accounts > > The scopes you set here apply to every connected account that uses this connection. If you need different scopes for different user groups, create separate connections for each group. 6. ### Save the connection Click **Save**. The connection is now active and ready for connected accounts to be created against it. > tip: Use Scalekit credentials to get started faster > > For many OAuth connectors, the connection form offers a **Use Scalekit credentials** option. This lets you skip OAuth app registration, in development and in production. Switch to your own credentials when you want your app's name on the consent screen. See [Bring your own credentials](https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth/). ## Set up a non-OAuth connection For connectors that use API keys, basic auth, key pairs, or similar, the connection form asks for very little. In many cases, you only need to give the connection a name. The user provides their own credentials (their API key, account details, or private key) when they create a connected account. Scalekit collects those credentials through the connected account form and stores them securely. 1. Go to **AgentKit** > **Connections** and click **Add connection** 2. Select the connector 3. Enter a **Connection name**: this identifies the connection in the dashboard and in your code 4. Click **Save** When a connected account is created for this connection, Scalekit presents the user with a form that collects the credentials their specific account requires. ## Create multiple connections for the same connector You can create more than one connection for the same connector. This is useful when: - Different groups of users need different scopes - You want to maintain separate OAuth apps for staging and production - You're integrating with multiple instances of the same service (for example, two different Salesforce orgs) Each connection has its own name, which you use to identify it in API calls and in the dashboard. ## Common scenarios If a user can't finish connecting, find the error on [Troubleshoot connection and OAuth errors](https://docs.scalekit.com/agentkit/troubleshooting/): - [`failed_to_exchange_token`](https://docs.scalekit.com/agentkit/troubleshooting/#authorization-failed-failed_to_exchange_token): the page says **Authorization failed** after the user approved access. - [`session_not_found`](https://docs.scalekit.com/agentkit/troubleshooting/#session-expired-or-not-available-session_not_found): the page says **Session expired or not available**. - [`redirect_uri_mismatch`](https://docs.scalekit.com/agentkit/troubleshooting/#redirect_uri_mismatch): the provider rejects the redirect URI. - [It works for your account but fails for your customers](https://docs.scalekit.com/agentkit/troubleshooting/#it-works-for-your-account-but-fails-for-your-customers): the provider's OAuth app is still in development or testing. ## Next - [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/): Create a connected account and send the user an authorization link. - [Troubleshoot connection and OAuth errors](https://docs.scalekit.com/agentkit/troubleshooting/): Find the error your user saw and how to fix it. --- Source: https://docs.scalekit.com/agentkit/tools/authorize.md # Authorize a user Generate an authorization link, send it to your user, and confirm their connected account is active before your agent executes tools. Once a connection is configured, your users need to grant your agent access to their account. This happens once per user per connection. Scalekit stores their tokens and keeps them fresh automatically. The flow is: 1. Create a connected account for the user 2. Generate an authorization link and send it to the user 3. The user completes the OAuth consent screen 4. The connected account becomes `ACTIVE`. Your agent can now execute tools. ## Create a connected account and generate a link These samples use `actions`, the client from the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/): `scalekit_client.actions` in Python, `scalekit.actions` in Node.js. **Python** ```python # Create or retrieve the connected account for this user response = actions.get_or_create_connected_account( connection_name="github-connect", identifier="user_123" # your app's unique user ID ) connected_account = response.connected_account # Generate the authorization link if the account is not yet active if connected_account.status != "ACTIVE": link_response = actions.get_authorization_link( connection_name="github-connect", identifier="user_123" ) auth_url = link_response.link # Redirect or send auth_url to the user ``` **Node.js** ```typescript import { ConnectorStatus } from '@scalekit-sdk/node'; // Create or retrieve the connected account for this user const response = await actions.getOrCreateConnectedAccount({ connectionName: 'github-connect', identifier: 'user_123', // your app's unique user ID }); const connectedAccount = response.connectedAccount; // Generate the authorization link if the account is not yet active if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const linkResponse = await actions.getAuthorizationLink({ connectionName: 'github-connect', identifier: 'user_123', }); const authUrl = linkResponse.link; // Redirect or send authUrl to the user } ``` > note: Custom user verifier needs user_verify_url > > If the environment uses the **Custom user verifier** mode, also pass `user_verify_url` and `state` when you request the link. Without `user_verify_url`, the request fails. See [Verify users](https://docs.scalekit.com/agentkit/user-verification/#generate-the-authorization-link). By default, the link expires five minutes after you create it. The response's `expiry` field gives the exact time, so create the link when you're ready to send it, and create a new one if it expires. ## Send the link to the user How you deliver the link depends on your application: - **Web app:** redirect the user to `auth_url` directly if they're in an active browser session - **Email or notification:** send the link when the user isn't actively in your app, or when connecting at their own pace is acceptable - **In-app prompt:** show a button ("Connect GitHub") when you want to prompt connection at a specific moment in the user's workflow Once the user opens the link and approves the OAuth consent screen, Scalekit exchanges the authorization code for tokens and marks the connected account `ACTIVE`. You do not need to handle the OAuth callback yourself. To learn when the user has finished, [listen for account events](https://docs.scalekit.com/agentkit/account-events/): a `connected_account.status_updated` event with status `ACTIVE` arrives when the account activates. Or check the status again with `get_connected_account_details` in Python or `getConnectedAccount` in Node.js. > note: Production: add user verification > > New environments use the **None** verification mode, so any user who completes the OAuth flow activates the connected account. Before production, switch to **Custom user verifier** so your app confirms the authorizing user is the one it intended to connect. See [Verify user identity](https://docs.scalekit.com/agentkit/user-verification/). ## Check status and re-authorize Check the connected account status before executing tools. Tokens can expire or be revoked, so generate a new authorization link using the same flow when that happens. **Python** ```python response = actions.get_or_create_connected_account( connection_name="github-connect", identifier="user_123" ) connected_account = response.connected_account # ACTIVE: ready for tool calls # PENDING_AUTH: the user hasn't completed authorization yet # PENDING_VERIFICATION: authorized, but user verification is still pending # EXPIRED: tokens expired or were revoked; the user must authorize again # DISCONNECTED: the account was disconnected if connected_account.status != "ACTIVE": link_response = actions.get_authorization_link( connection_name="github-connect", identifier="user_123" ) # Redirect or send link_response.link to the user ``` **Node.js** ```typescript import { ConnectorStatus } from '@scalekit-sdk/node'; const response = await actions.getOrCreateConnectedAccount({ connectionName: 'github-connect', identifier: 'user_123', }); const connectedAccount = response.connectedAccount; // ACTIVE: ready for tool calls // PENDING_AUTH: the user hasn't completed authorization yet // PENDING_VERIFICATION: authorized, but user verification is still pending // EXPIRED: tokens expired or were revoked; the user must authorize again // DISCONNECTED: the account was disconnected if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const linkResponse = await actions.getAuthorizationLink({ connectionName: 'github-connect', identifier: 'user_123', }); // Redirect or send linkResponse.link to the user } ``` ## Common problems ### `user_verify_url is required when user_verify_mode is b2b` The environment uses the **Custom user verifier** mode, and the link request didn't include `user_verify_url`. The request fails with a `400`. Pass `user_verify_url` and `state` as shown in [Verify users](https://docs.scalekit.com/agentkit/user-verification/#generate-the-authorization-link), or switch to **Scalekit users only** for internal testing. ### The account stays `PENDING_AUTH` The user didn't finish authorizing, or the link expired before they opened it. Create a new link and send it again. ## Next - [Verify users](https://docs.scalekit.com/agentkit/user-verification/): Confirm that the person who authorized is the user you meant. - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) --- Source: https://docs.scalekit.com/agentkit/user-verification.md # Verify users Confirm that the person who authorized an AgentKit connection is the user your app meant, with a verification callback in production or Scalekit users only. User verification applies to OAuth-based connectors only. For API key, basic auth, and key pair connectors, the user provides credentials directly. No OAuth flow, no verification step needed. For OAuth connectors, **user verification** confirms that the user who completed the OAuth consent is the same user your app intended to connect, before Scalekit activates the connected account. It stops an authorization link from activating the wrong account, for example when a link is forwarded or phished. Choose a mode in **AgentKit** > **Settings** > **User Verification**: - **Custom user verifier** (recommended): your server confirms that the authorizing user matches the user your app intended to connect. Use it in production. - **Scalekit users only**: Scalekit checks that the authorizing user is invited to your Scalekit workspace and signed in to the dashboard. No code required. Use it for development and testing, when every user is on your team. - **None**: no verification. The connected account activates as soon as OAuth completes, so anyone who opens an authorization link can activate it. New environments start with **None**, because your agent may run somewhere that can't confirm which user is signed in. Switch to **Custom user verifier** before you onboard real users. > note: Scalekit users only is for testing > > In this mode, the user authorizing the connection must already be signed in to the Scalekit dashboard. No verify route or API calls are needed in your code. Switch to **Custom user verifier** before onboarding real users. > Image: User Verification settings in the Scalekit dashboard, with the Custom user verifier, Scalekit users only and None modes With **Custom user verifier**, your application implements the verify step. End users never interact with Scalekit directly. When the user finishes OAuth, Scalekit redirects to your verify URL with `auth_request_id` and `state` params. Your route reads the user from your session, calls Scalekit's verify API with the `auth_request_id` and the original `identifier`, and if they match, the connected account activates. ** Review the verification sequence** ```d2 title="Connected account user verification sequence: authorization link, OAuth consent, authorization code, and redirect to your app for verification" title: "Connected account user verification" { near: top-center shape: text style.font-size: 18 } shape: sequence_diagram Your app Scalekit Provider End user Your app -> Scalekit: POST authorization link\n(identifier, user_verify_url, state) Scalekit -> Your app: Authorization link URL Your app -> End user: Deliver link\n(email, in-app, …) End user -> Scalekit: Open authorization link Scalekit -> Provider: OAuth consent screen Provider -> Scalekit: Authorization code Scalekit -> Scalekit: Store tokens\n(pending verification) Scalekit -> End user: Redirect to user_verify_url\n(auth_request_id, state) End user -> Your app: GET /user/verify\n(?auth_request_id, state) Your app -> Your app: Validate state,\nread user from session Your app -> Scalekit: POST verify\n(auth_request_id, identifier) Scalekit -> Scalekit: Match identifier,\nactivate connected account Scalekit -> Your app: post_user_verify_redirect_url Your app -> End user: Redirect to your app ``` ## Implement verification in your app If you haven't installed the SDK yet, see the [quickstart](https://docs.scalekit.com/agentkit/quickstart/). ### Generate the authorization link Pass these fields when creating the authorization link: | Field | Description | |---|---| | `identifier` | **Required.** Your user's ID in your system, such as `user_123`. Use a stable internal ID, not an email address: a user can change their email, and an old address can be reassigned to someone else, so the identifier would no longer point to the same person. An email is also easy to guess, which makes misuse easier. Scalekit stores it and checks that the verify call sends the same value. | | `user_verify_url` | **Required.** Your callback URL; Scalekit redirects the user here after OAuth completes. | | `state` | **Recommended.** A random value to prevent CSRF. | > note: How to use state > > Generate a cryptographically random value per flow, store it in a secure HTTP-only cookie, and validate it against the `state` query param on callback. Discard the request if they don't match; this prevents an attacker from sending crafted verify URLs to your users. **Python** ```python import secrets # Generate a state value to prevent CSRF state = secrets.token_urlsafe(32) # Store state in a secure, HTTP-only cookie to validate on callback response = scalekit_client.actions.get_authorization_link( connection_name=connector, identifier=user_id, user_verify_url="https://app.yourapp.com/user/verify", state=state, ) ``` **Node.js** ```typescript import crypto from 'node:crypto'; // Generate a state value to prevent CSRF const state = crypto.randomUUID(); // Store state in a secure, HTTP-only cookie to validate on callback const { link } = await scalekit.actions.getAuthorizationLink({ identifier: userId, connectionName: connector, userVerifyUrl: 'https://app.yourapp.com/user/verify', state, }); ``` ### Handle the verification callback After OAuth completes, Scalekit redirects to your `user_verify_url`: ```http GET https://app.yourapp.com/user/verify?auth_request_id=req_xyz&state= ``` Validate `state` against your cookie, then call Scalekit's verify endpoint server-side. > caution: Never trust query params for identity > > Read the user's identity from your own session, not from the URL. Use `state` for session correlation only. **Python** ```python # 1. Validate state from query param matches state in cookie # 2. Read user identity from your session, not from the URL response = scalekit_client.actions.verify_connected_account_user( auth_request_id=auth_request_id, identifier=user_id, # must match what was stored at link creation ) # On success: redirect to response.post_user_verify_redirect_url ``` **Node.js** ```typescript // 1. Validate state from query param matches state in cookie // 2. Read user identity from your session, not from the URL const { postUserVerifyRedirectUrl } = await scalekit.actions.verifyConnectedAccountUser({ authRequestId: auth_request_id, identifier: userId, // must match what was stored at link creation }); // On success: redirect to postUserVerifyRedirectUrl ``` On success, the connected account is activated. Redirect the user using `post_user_verify_redirect_url`. ## Common scenarios **Why does creating the authorization link fail with `user_verify_url is required when user_verify_mode is b2b`?** The environment is set to **Custom user verifier**, and the request for the authorization link didn't include `user_verify_url`. The call fails with a `400` before any link is created. Pass `user_verify_url` and `state` when you [generate the authorization link](#generate-the-authorization-link), or, for internal testing, switch the mode to **Scalekit users only**. **Why does authorization fail when no verification redirect URL is configured?** The authorization flow fails with a `failed_to_exchange_token` error (`user_verify_url not configured for verification redirect`) when the environment is set to **Custom user verifier**, but the flow started without a `user_verify_url`. Scalekit completes the OAuth exchange but has nowhere to redirect the user for verification. Resolve it in one of two ways: - **In production**, pass `user_verify_url` when you generate the authorization link, and implement the [verification callback](#handle-the-verification-callback) at that URL. - **In development or internal testing**, set the mode to **Scalekit users only** in **AgentKit** > **Settings** > **User Verification**. This mode needs no `user_verify_url` and no verify route, as long as every authorizing user is signed in to your Scalekit dashboard. **Why does the connection page say "Session expired or not available"?** The environment uses **Scalekit users only**, and the person authorizing isn't signed in to the Scalekit dashboard. Sign in to the dashboard in the same browser and start again, or switch to **Custom user verifier** for users who aren't on your team. See [`session_not_found`](https://docs.scalekit.com/agentkit/troubleshooting/#session-expired-or-not-available-session_not_found). ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [Troubleshoot connection and OAuth errors](https://docs.scalekit.com/agentkit/troubleshooting/): Find the error your user saw and how to fix it. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) - `POST` [Verify the user](https://docs.scalekit.com/agentkit/reference/authorization/verify-the-user.md) --- Source: https://docs.scalekit.com/agentkit/connected-accounts.md # Manage connected accounts Manage AgentKit connected accounts: check an account's status, detect when a user must reconnect, list and delete accounts, and request more OAuth scopes. A **connected account** is the per-user record that holds a user's credentials and tracks their authorization state for a specific connection. Your code creates it, with `get_or_create_connected_account` or when it requests an authorization link for the user. The user activates it by authorizing. ## Account states | State | Meaning | |---|---| | `ACTIVE` | Credentials valid, ready for tool calls | | `EXPIRED` | Tokens expired or were revoked and couldn't be refreshed; the user must authorize again | | `PENDING_AUTH` | The user hasn't finished authorizing, or is authorizing again | | `PENDING_VERIFICATION` | OAuth complete; user identity verification still required before activation | | `DISCONNECTED` | Account was manually disconnected | See [Troubleshoot connection and OAuth errors](https://docs.scalekit.com/agentkit/troubleshooting/#connected-account-status) for what to do in each state. ## Check account status Use `get_or_create_connected_account` as the safe default when a user may be connecting for the first time. When the account already exists and you only need its status, use `get_connected_account_details` in Python, which returns the account without its credentials. In Node.js, use `getConnectedAccount`. **Python** ```python response = actions.get_or_create_connected_account( connection_name="github-connect", identifier="user_123" ) connected_account = response.connected_account print(f"Status: {connected_account.status}") ``` **Node.js** ```typescript const response = await actions.getOrCreateConnectedAccount({ connectionName: 'github-connect', identifier: 'user_123', }); console.log('Status:', response.connectedAccount?.status); ``` ## Handle inactive accounts When a connected account isn't `ACTIVE`, generate a new authorization link and send it to the user. The link opens a Scalekit-hosted page that adapts automatically based on the connection's auth type: - **OAuth connectors**: presents the provider's OAuth consent screen - **API key, basic auth, or other connectors**: presents a form to collect the required credentials Your code is the same regardless of connector type. Scalekit determines the right flow based on the connection configuration. **Python** ```python if connected_account.status != "ACTIVE": link_response = actions.get_authorization_link( connection_name="github-connect", identifier="user_123" ) # Redirect or send link_response.link to the user ``` **Node.js** ```typescript import { ConnectorStatus } from '@scalekit-sdk/node'; if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const linkResponse = await actions.getAuthorizationLink({ connectionName: 'github-connect', identifier: 'user_123', }); // Redirect or send linkResponse.link to the user } ``` > tip: Customize hosted pages > > By default, hosted pages use Scalekit's branding. You can configure your own logo, colors, and custom domain so the pages look like part of your product. See [Custom domain](https://docs.scalekit.com/agentkit/advanced/custom-domain/). ## Detect when a user must authorize again A connected account can leave the `ACTIVE` state on its own, with no action from you or the user. When that happens, the next tool call fails until the user authorizes again. To catch it early, [listen for the `connected_account.status_updated` webhook](https://docs.scalekit.com/agentkit/account-events/) instead of waiting for a failed call. ### Common causes OAuth connected accounts most often move to `EXPIRED` because: - **The provider revoked the refresh token.** A password change, an admin-initiated token revocation, or a provider security policy invalidates the refresh token, so Scalekit can no longer obtain new access tokens. - **The refresh token expired.** Providers cap refresh-token lifetimes (for example, 30 or 180 days), and the expiry is rarely surfaced in advance. - **No refresh token was issued.** When the connection's scopes don't request offline access, the provider returns only a short-lived access token and no refresh token to renew it. - **The provider hit a per-user token limit.** Some providers keep only a fixed number of refresh tokens per user and app, and silently drop the oldest ones when a user reconnects repeatedly. In every case, the user authorizes again. When no refresh token was issued, first add the provider's offline-access scope to the connection, or the account expires again soon after. Offline scopes don't raise a provider's per-user token limit: to stay under it, send a new authorization link only when the account isn't `ACTIVE`. ### Subscribe to status changes Scalekit sends a `connected_account.status_updated` webhook when an account's status changes, with the old and new status. When `status` becomes `EXPIRED` or `DISCONNECTED`, [create a new authorization link](#handle-inactive-accounts) and ask the user to reconnect. [Listen for account events](https://docs.scalekit.com/agentkit/account-events/) shows how to add an endpoint, verify the signature, and handle each event. ## List connected accounts **Python** ```python list_response = actions.list_connected_accounts( connection_name="github-connect", ) for account in list_response.connected_accounts: print(account.identifier, account.status) ``` **Node.js** ```typescript import { ConnectorStatus } from '@scalekit-sdk/node'; const listResponse = await actions.listConnectedAccounts({ connectionName: 'github-connect', }); for (const account of listResponse.connectedAccounts) { console.log(account.id, account.identifier, ConnectorStatus[account.status]); } ``` ## Delete a connected account Deleting a connected account removes the user's credentials and authorization state. The user must authorize again to reconnect. **Python** ```python actions.delete_connected_account( connection_name="github-connect", identifier="user_123", ) ``` **Node.js** ```typescript await actions.deleteConnectedAccount({ connectionName: 'github-connect', identifier: 'user_123', }); ``` ## Update OAuth scopes Scopes apply to OAuth connectors only. For non-OAuth connectors (API key, basic auth, and similar), generate a new authorization link and the hosted page will collect updated credentials. To request additional OAuth scopes from an existing connected account: 1. Update the connection's scopes in **AgentKit** > **Connections** > **Edit**. 2. Generate a new authorization link for the user. 3. The user completes the OAuth consent screen, approving the updated scopes. 4. Scalekit updates the connected account with the new token set. ## Next - [Listen for account events](https://docs.scalekit.com/agentkit/account-events/): Get a webhook when an account expires or is disconnected. - [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/): Call a tool as the user with execute_tool. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `GET` [List connected accounts](https://docs.scalekit.com/agentkit/reference/connected-accounts/list-connected-accounts.md) - `POST` [Delete a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/delete-a-connected-account.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) --- Source: https://docs.scalekit.com/agentkit/account-events.md # Listen for account events Get a webhook when an AgentKit connected account changes. Verify the signature in Python or Node.js, and ask users to reconnect when an account expires. Scalekit sends a webhook to your server when a connected account changes: when a user finishes authorizing, when a token can't be refreshed, or when an account expires or is disconnected. Listen for these events to ask users to reconnect before their next tool call fails, instead of polling every account's status. ## Before you start - A server with a public HTTPS URL that can receive `POST` requests. While you develop, a tunnel to your local server works. - The Scalekit SDK installed, as in the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/). It verifies webhook signatures. - The **Admin** or **Developer** role, to create an endpoint and read its signing secret. See [Team members and roles](https://docs.scalekit.com/agentkit/team-members/). ## Events | Event | Sent when | | --- | --- | | `connected_account.created` | A connected account is created | | `connected_account.updated` | A connected account's details are updated | | `connected_account.deleted` | A connected account is deleted | | `connected_account.magic_link_generated` | An authorization link is created for the account | | `connected_account.oauth_tokens_fetched` | The app returned tokens after the user approved access | | `connected_account.oauth_succeeded` | The user finished authorizing, including user verification when it's on | | `connected_account.token_refresh_succeeded` | Scalekit refreshed the account's access token | | `connected_account.token_refresh_failed` | Scalekit couldn't refresh the access token | | `connected_account.status_updated` | The account's status changed, for example from `ACTIVE` to `EXPIRED` | To know when a user needs to act, `connected_account.status_updated` is the one to handle. It's sent only when the status actually changes, not when an account is created, and it carries the old and new status. `connected_account.token_refresh_failed` arrives before the status changes, so its `status` often still reads `ACTIVE`, and it doesn't say why the refresh failed. Use it as an early warning, and wait for `status_updated` before you ask the user to reconnect. ## Payloads Every event has the same envelope. The account's details are in `data`: ```json title="connected_account.status_updated" { "spec_version": "1", "id": "evt_101652975398683158", "type": "connected_account.status_updated", "occurred_at": "2026-09-26T06:31:34.895815554Z", "environment_id": "env_88640229614813449", "object": "ConnectedAccount", "data": { "id": "ca_133400349586228019", "identifier": "user_123", "connection_id": "conn_133400101014995480", "connection_name": "github-connect", "provider": "GITHUB", "authorization_type": "OAUTH", "status": "EXPIRED", "old_status": "ACTIVE" } } ``` | Field | Meaning | | --- | --- | | `id` | The event's ID. The same event delivered twice has the same ID | | `type` | The event name from the table above | | `occurred_at` | When the change happened | | `data.id` | The connected account's ID | | `data.identifier` | Your app's ID for the user | | `data.connection_name` | The connection, as named in **AgentKit** > **Connections**. It can be missing from `status_updated`, so read it as optional and fall back to `data.connection_id` | | `data.status` | The status after the change: `ACTIVE`, `PENDING_AUTH`, `PENDING_VERIFICATION`, `EXPIRED` or `DISCONNECTED` | | `data.old_status` | The status before the change. Only in `status_updated` | Events other than `status_updated` have no `old_status`. They also include `token_expires_at` and `last_used_at`, which are empty strings when Scalekit has no value. In webhooks, `status` is a string, such as `ACTIVE`, in both Python and Node.js. For every event's full schema, see [Connected account events](https://docs.scalekit.com/agentkit/reference/events/) in the REST reference. 1. ## Add a webhook endpoint In the Scalekit dashboard, select the environment, then open **Developers** > **Webhooks** and select **Add Webhook**. Enter a **Display Name** and your **Endpoint URL**, choose the events under **Choose events to subscribe**, and save. Each environment has its own endpoints. Add one in Production too when you go live. 2. ## Copy the signing secret Open the endpoint and select **Show** under **Signing secret**. It starts with `whsec_`. Store it with your other server secrets, for example as `SCALEKIT_WEBHOOK_SECRET`, and never expose it to a browser. 3. ## Verify each request and handle the event Scalekit signs every request with the secret. Verify the signature against the raw request body before you trust the payload, so a forged request can't prompt the wrong user to reconnect. The SDK checks the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, and rejects requests older than five minutes. **Python** ```python title="webhooks.py" import json import os from fastapi import FastAPI, HTTPException, Request from scalekit import ScalekitClient scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) app = FastAPI() seen_event_ids = set() # use your database in production @app.post("/webhooks/scalekit") async def scalekit_webhook(request: Request): body = await request.body() headers = {k.lower(): v for k, v in request.headers.items()} try: scalekit_client.verify_webhook_payload( os.environ["SCALEKIT_WEBHOOK_SECRET"], headers, body ) except Exception: raise HTTPException(status_code=400, detail="invalid signature") event = json.loads(body) if event["id"] in seen_event_ids: return {"ok": True} seen_event_ids.add(event["id"]) data = event["data"] if event["type"] == "connected_account.status_updated": # connection_name can be missing; fall back to connection_id connection = data.get("connection_name") or data.get("connection_id") if data.get("status") in ("EXPIRED", "DISCONNECTED"): # Ask this user to reconnect, for example with a new authorization link print(f"{data.get('identifier')}: reconnect {connection}") elif data.get("status") == "ACTIVE": print(f"{data.get('identifier')}: {connection} is connected") return {"ok": True} ``` **Node.js** ```ts title="webhooks.ts" import express from 'express'; import { ScalekitClient } from '@scalekit-sdk/node'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ); const app = express(); const seenEventIds = new Set(); // use your database in production // Read the raw body: the signature covers the exact bytes Scalekit sent app.post('/webhooks/scalekit', express.raw({ type: 'application/json' }), (req, res) => { const body = req.body.toString('utf8'); try { scalekit.verifyWebhookPayload( process.env.SCALEKIT_WEBHOOK_SECRET!, req.headers as Record, body, ); } catch { return res.status(400).send('invalid signature'); } const event = JSON.parse(body); if (seenEventIds.has(event.id)) return res.json({ ok: true }); seenEventIds.add(event.id); const { data } = event; if (event.type === 'connected_account.status_updated') { // connection_name can be missing; fall back to connection_id const connection = data?.connection_name ?? data?.connection_id; if (data?.status === 'EXPIRED' || data?.status === 'DISCONNECTED') { // Ask this user to reconnect, for example with a new authorization link console.log(`${data?.identifier}: reconnect ${connection}`); } else if (data?.status === 'ACTIVE') { console.log(`${data?.identifier}: ${connection} is connected`); } } res.json({ ok: true }); }); app.listen(3000); ``` To ask the user to reconnect, create an authorization link for `data.identifier` and `data.connection_name`, as shown in [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/). ## Respond quickly and handle repeats - **Return a `2xx` status fast.** Do slow work, such as sending an email, after you respond or in a background job. Scalekit retries deliveries that fail or time out. - **Expect the same event more than once.** Retries and resends repeat an event with the same `id`. Store the IDs you've handled, and skip a repeat. - **Don't rely on order.** Events can arrive out of order. Compare `occurred_at`, or read the account's current status (`get_connected_account_details` in Python, `getConnectedAccount` in Node.js) before you act on an old event. ## Check it worked In your Development environment, create an authorization link for a test user and complete it. Your endpoint receives events including `connected_account.magic_link_generated`, `connected_account.oauth_succeeded` and a `connected_account.status_updated` whose `status` is `ACTIVE`. The endpoint's page in **Developers** > **Webhooks** lists every delivery and its result. Resend a delivery from there to test your handler again. ## Common problems ### The SDK raises `Missing required headers` The `webhook-id`, `webhook-timestamp` and `webhook-signature` headers didn't reach the SDK. In Python, pass the headers with lowercase keys. Check that a proxy in front of your server doesn't strip them. ### The SDK raises `Invalid signature` or `Invalid Signature` Python raises `Invalid signature`, and Node.js raises `Invalid Signature`. The body you verified isn't byte-for-byte what Scalekit sent, or the secret is from another endpoint or environment. Verify the raw body before any JSON parsing, and copy the secret again from the endpoint's page. ### The SDK raises `Message timestamp too old` The request is more than five minutes old, or your server's clock is off. Sync the clock, and don't queue requests before you verify them. ### My endpoint receives nothing Check that the endpoint is in the environment where the change happened, that it subscribes to the event, and that its URL is reachable from the internet. The delivery list on the endpoint's page shows failed attempts and their responses. ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and why accounts expire. - [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/): Send the user a new authorization link to reconnect. --- Source: https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools.md # Use built-in tools Find the tools a user can call, run one with execute_tool, and pass the tool list to your model. Python, Node.js and cURL. Every connector ships prebuilt tools, such as `github_user_repos_list` or `slack_send_message`. Each has a JSON Schema for its input and returns structured output. Your code names the tool and the user; Scalekit adds that user's credentials, calls the app's API and returns the result. This page finds a tool, runs it, and hands the tool list to your model. ## Before you start - An `ACTIVE` connected account for the user. [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) shows how to get one. - The connection name from **AgentKit** > **Connections**. These examples use `github-connect`, the GitHub connection new environments include. - A client set up as in the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/): `actions` in Python, `scalekit` in Node.js. For cURL, get an access token and keep it in `TOKEN`: ```bash TOKEN=$(curl -sS "$SCALEKIT_ENVIRONMENT_URL/oauth/token" \ -d grant_type=client_credentials \ -d client_id="$SCALEKIT_CLIENT_ID" \ -d client_secret="$SCALEKIT_CLIENT_SECRET" | jq -r .access_token) ``` 1. ## Find the right tool Search by the job you want done. Pass the user's identifier to see, for each tool, whether that user can call it now. **Python** ```python from scalekit.v1.tools.tools_pb2 import ToolReadinessState results, _ = actions.tools.search_tools( query="list my GitHub repositories", identifier="user_123", top_k=5, ) for tool in results.tools: ready = [ c.connected_account_id for c in tool.connections if c.readiness_state == ToolReadinessState.TOOL_READINESS_STATE_READY ] print(tool.name, round(tool.score, 2), "ready" if ready else "not connected") ``` **Node.js** ```ts import { ToolReadinessState } from '@scalekit-sdk/node'; const { tools } = await scalekit.tools.searchTools('list my GitHub repositories', { identifier: 'user_123', topK: 5, }); for (const tool of tools) { const ready = tool.connections.filter((c) => c.readinessState === ToolReadinessState.READY); console.log(tool.name, tool.score.toFixed(2), ready.length ? 'ready' : 'not connected'); } ``` **cURL** ```bash curl -sS "$SCALEKIT_ENVIRONMENT_URL/api/v1/tools:search" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "list my GitHub repositories", "identifier": "user_123", "top_k": 5}' | jq '.tools[] | {name, score, connections}' ``` Each result has a `score` (higher is closer) and a `connections` list with a `readiness_state` per connection: - `READY`: the user has an `ACTIVE` account on that connection. Call the tool now. - `NEEDS_CONNECTION`: the account exists but isn't active. [Send a new authorization link](https://docs.scalekit.com/agentkit/tools/authorize/). - `NEEDS_REAUTH`: the token expired. Send a new authorization link. A connector the user has never connected isn't in `connections` at all. To get every tool a user can call on a connection, list the scoped tools instead. Pages hold up to 100 tools; pass `next_page_token` back until it's empty. **Python** ```python from google.protobuf.json_format import MessageToDict definitions = [] page_token = None while True: page, _ = actions.tools.list_scoped_tools( identifier="user_123", filter={"connection_names": ["github-connect"]}, page_size=100, page_token=page_token, ) definitions += [MessageToDict(t.tool)["definition"] for t in page.tools] page_token = page.next_page_token if not page_token: break print(len(definitions), [d["name"] for d in definitions[:5]]) ``` **Node.js** ```ts const definitions = []; let pageToken: string | undefined; do { const page = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: ['github-connect'] }, pageSize: 100, pageToken, }); for (const t of page.tools) { if (t.tool?.definition) definitions.push(t.tool.definition); } pageToken = page.nextPageToken || undefined; } while (pageToken); console.log(definitions.length, definitions.slice(0, 5).map((d) => d.name)); ``` **cURL** ```bash curl -sS -G "$SCALEKIT_ENVIRONMENT_URL/api/v1/tools/scoped" \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "identifier=user_123" \ --data-urlencode "filter.connection_names=github-connect" \ --data-urlencode "page_size=100" | jq '{names: [.tools[].tool.definition.name], next_page_token}' ``` 2. ## Call the tool Pass the tool name, its input, and the user. Identify the user with `identifier` plus the connection name, or with `connected_account_id` alone, which you get from a search result's `connections` or from `get_connected_account`. When you pass `connected_account_id`, Scalekit ignores `identifier`. **Python** ```python result = actions.execute_tool( tool_name="github_user_repos_list", identifier="user_123", connection_name="github-connect", tool_input={"per_page": 5, "sort": "updated"}, ) print(result.execution_id) print(result.data) ``` **Node.js** ```ts const result = await scalekit.actions.executeTool({ toolName: 'github_user_repos_list', identifier: 'user_123', connector: 'github-connect', toolInput: { per_page: 5, sort: 'updated' }, }); console.log(result.executionId); console.log(result.data); ``` **cURL** ```bash curl -sS "$SCALEKIT_ENVIRONMENT_URL/api/v1/execute_tool" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "tool_name": "github_user_repos_list", "connector": "github-connect", "identifier": "user_123", "params": {"per_page": 5, "sort": "updated"} }' | jq '{execution_id, data}' ``` 3. ## Read the result The response wraps the app's output. Read the tool's fields from `data`, and keep `execution_id` to find the call in your logs. Some apps report an error in a successful (`2xx`) response. Then the call still returns, and `data` holds the app's error. Slack, for example, returns: ```json { "ok": false, "error": "missing_scope", "needed": "users:read" } ``` Check `data` for the app's error shape before you use it. When the app answers with an error status instead, such as `401`, `403`, `429` or another `4xx` or `5xx`, the call fails with an error whose message starts with `tool execution failed`. See [Common problems](#common-problems). 4. ## Give the tools to your model Tool definitions carry more than a model needs. Keep `name`, `description` and `input_schema`, a JSON Schema object for the tool's input. Anthropic's API takes it as `input_schema` unchanged, and OpenAI's takes it as a function's `parameters`. This continues from the list in step 1. **Python** ```python llm_tools = [ { "name": d["name"], "description": d.get("description", ""), "input_schema": d.get("input_schema", {"type": "object", "properties": {}}), } for d in definitions ] read_only = [d["name"] for d in definitions if d.get("annotations", {}).get("read_only_hint")] print(len(llm_tools), "tools,", len(read_only), "read-only") ``` **Node.js** ```ts const llmTools = definitions.map((d) => ({ name: String(d.name), description: String(d.description ?? ''), input_schema: d.input_schema ?? { type: 'object', properties: {} }, })); const readOnly = definitions.filter( (d) => (d.annotations as { read_only_hint?: boolean } | undefined)?.read_only_hint, ); console.log(llmTools.length, 'tools,', readOnly.length, 'read-only'); ``` **cURL** ```bash curl -sS -G "$SCALEKIT_ENVIRONMENT_URL/api/v1/tools/scoped" \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "identifier=user_123" \ --data-urlencode "filter.connection_names=github-connect" \ --data-urlencode "page_size=100" | jq '[.tools[].tool.definition | {name, description, input_schema}]' ``` Each definition's `annotations` include `read_only_hint` and `destructive_hint`. Use them to start an agent with read-only tools, or to ask the user before a destructive call. When the model asks for a tool, call it as in step 2 and send `data` back as the tool result. LangChain and Google ADK get native tool objects from `actions.langchain.get_tools` and `actions.google.get_tools`. [Choose a framework](https://docs.scalekit.com/agentkit/examples/) has a complete agent for each framework. ## Check it worked The call returns an `execution_id` and a `data` object with the app's response, such as a list of repositories for `github_user_repos_list`. Search results for the user show `READY` for the connection you called. ## Common problems ### `failed to get tool: ` That tool name doesn't exist in your environment. Use a name from search or from the scoped list, exactly as returned. ### `connection not found for the given key` The connection name doesn't match **AgentKit** > **Connections** exactly. Connection names can differ between environments, so read them from configuration rather than hard-coding them. ### `connected account not found` No connected account exists for this identifier on this connection. Check that you pass the same identifier you used when the user authorized, or [authorize the user](https://docs.scalekit.com/agentkit/tools/authorize/) first. ### `connected account is not active` The user hasn't finished authorizing, or the account expired or was disconnected. Send a new [authorization link](https://docs.scalekit.com/agentkit/tools/authorize/) and wait until the status is `ACTIVE`. ### `missing or invalid parameter` The input doesn't match the tool's `input_schema`. Check required fields and types in the tool's definition. ### The call returns, but `data` holds an error such as `missing_scope` The app refused the call in a `2xx` response. For a missing scope, add the scope to the connection, then have the user authorize again so their token includes it. ### `tool execution failed - unauthorized access` The app answered `401`: it rejected the user's token. Check the connected account's status, and if the user revoked access, send a new [authorization link](https://docs.scalekit.com/agentkit/tools/authorize/). ### `tool execution failed - forbidden access` The app answered `403`. The user's token is usually missing a scope the tool needs, or the user lacks permission in the app. Add the scope to the connection, then have the user authorize again. ### `tool execution failed - rate limited` The app answered `429`. Wait before you retry, and slow down calls for this user. ### `tool execution failed - ` The app answered with another error status. The rest of the message is the app's own error. Check the input against the tool's `input_schema` and the app's API reference. ## Next - [Call any API](https://docs.scalekit.com/agentkit/tools/custom-tools/): Reach an endpoint no built-in tool covers, with the user's credentials. - [Choose a framework](https://docs.scalekit.com/agentkit/examples/): A complete agent in LangChain, OpenAI, Anthropic and more. To serve these tools to an MCP client instead, [create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) - `GET` [List a user's scoped tools](https://docs.scalekit.com/agentkit/reference/tools/list-scoped-tools.md) - `POST` [Search tools](https://docs.scalekit.com/agentkit/reference/tools/search-tools.md) --- Source: https://docs.scalekit.com/agentkit/tools/custom-tools.md # Call any API Call any endpoint of a connector's API as the user through the Scalekit API proxy, then wrap it as a tool for your agent. Python, Node.js and cURL. When no [built-in tool](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/) does what you need, call the app's API directly through the Scalekit proxy. You send the path, method and body; Scalekit adds the user's credentials, calls the app at its base URL and returns the raw response. Then you can wrap the call as your own tool. The proxy is on for most connectors. Search the built-in tools first: they come with schemas and structured output, and cover most of each app's API. ## Before you start - An `ACTIVE` connected account for the user. [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) shows how to get one. - The connection name from **AgentKit** > **Connections**. These examples use a Slack connection named `slack`. - A client set up as in the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/): `actions` in Python, `scalekit` in Node.js. For cURL, get an access token and keep it in `TOKEN`: ```bash TOKEN=$(curl -sS "$SCALEKIT_ENVIRONMENT_URL/oauth/token" \ -d grant_type=client_credentials \ -d client_id="$SCALEKIT_CLIENT_ID" \ -d client_secret="$SCALEKIT_CLIENT_SECRET" | jq -r .access_token) ``` - The app's API reference for the endpoint you want: | Connector | API reference | | --- | --- | | Gmail | [Gmail API](https://developers.google.com/gmail/api/reference/rest) | | Slack | [Slack API methods](https://api.slack.com/methods) | | GitHub | [GitHub REST API](https://docs.github.com/en/rest) | | Salesforce | [Salesforce REST API](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/) | | HubSpot | [HubSpot API](https://developers.hubspot.com/docs/api/overview) | 1. ## Check the connected account is active The proxy only works for an `ACTIVE` connected account. Check before you call, and send a new authorization link if it isn't. **Python** ```python account = actions.get_connected_account( connection_name="slack", identifier="user_123", ).connected_account if account.status != "ACTIVE": raise SystemExit(f"Slack is {account.status}. Send the user a new authorization link.") ``` **Node.js** ```ts import { ConnectorStatus } from '@scalekit-sdk/node'; const { connectedAccount: account } = await scalekit.actions.getConnectedAccount({ connectionName: 'slack', identifier: 'user_123', }); if (account?.status !== ConnectorStatus.ACTIVE) { throw new Error('Slack is not ACTIVE. Send the user a new authorization link.'); } ``` **cURL** ```bash curl -sS -G "$SCALEKIT_ENVIRONMENT_URL/api/v1/connected_accounts/details" \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "connector=slack" \ --data-urlencode "identifier=user_123" | jq -r '.connected_account.status' ``` 2. ## Call the endpoint Pass the path from the app's API reference, without the base URL. Query parameters go in `query_params`, and a JSON body goes in `body`. This reads the custom profile fields of the user's Slack workspace, which no built-in Slack tool covers: **Python** ```python response = actions.request( connection_name="slack", identifier="user_123", method="GET", path="/api/team.profile.get", query_params={"visibility": "visible"}, ) print(response.status_code, response.json()) ``` **Node.js** ```ts const response = await scalekit.actions.request({ connectionName: 'slack', identifier: 'user_123', method: 'GET', path: '/api/team.profile.get', queryParams: { visibility: 'visible' }, }); console.log(response.status, response.data); ``` **cURL** ```bash curl -sS -G "$SCALEKIT_ENVIRONMENT_URL/proxy/api/team.profile.get" \ -H "Authorization: Bearer $TOKEN" \ -H "connection_name: slack" \ -H "identifier: user_123" \ --data-urlencode "visibility=visible" ``` A write works the same way with a body. This sets a field on the user's Slack profile: **Python** ```python response = actions.request( connection_name="slack", identifier="user_123", method="POST", path="/api/users.profile.set", body={"profile": {"title": "Support engineer"}}, ) print(response.status_code, response.json()) ``` **Node.js** ```ts const response = await scalekit.actions.request({ connectionName: 'slack', identifier: 'user_123', method: 'POST', path: '/api/users.profile.set', body: { profile: { title: 'Support engineer' } }, }); console.log(response.status, response.data); ``` **cURL** ```bash curl -sS -X POST "$SCALEKIT_ENVIRONMENT_URL/proxy/api/users.profile.set" \ -H "Authorization: Bearer $TOKEN" \ -H "connection_name: slack" \ -H "identifier: user_123" \ -H "Content-Type: application/json" \ -d '{"profile": {"title": "Support engineer"}}' ``` The proxy returns the app's response as is: its status code, headers and body. In Python, `request` returns a `requests` response (`status_code`, `json()`) for every status, so check `status_code`. In Node.js, it returns an Axios response (`status`, `data`) for a `2xx` status, and throws a `ScalekitServerException` for any other status, so wrap the call in `try`/`catch`. 3. ## Wrap it as a tool Give your model a tool that matches what the agent needs to do, not the shape of the app's endpoint. Keep the input schema small, validate model input before you send it, and return only the fields the model needs. **Python** ```python list_profile_fields_tool = { "name": "slack_list_profile_fields", "description": "List the custom profile fields defined in the user's Slack workspace, " "such as Title or Pronouns. Returns each field's ID, label and type.", "input_schema": {"type": "object", "properties": {}}, } def slack_list_profile_fields(identifier: str) -> dict: response = actions.request( connection_name="slack", identifier=identifier, method="GET", path="/api/team.profile.get", ) data = response.json() if not data.get("ok"): return {"error": data.get("error")} fields = data.get("profile", {}).get("fields", []) return {"fields": [{"id": f["id"], "label": f["label"], "type": f["type"]} for f in fields]} print(slack_list_profile_fields("user_123")) ``` **Node.js** ```ts const listProfileFieldsTool = { name: 'slack_list_profile_fields', description: "List the custom profile fields defined in the user's Slack workspace, " + "such as Title or Pronouns. Returns each field's ID, label and type.", input_schema: { type: 'object', properties: {} }, }; type ProfileField = { id: string; label: string; type: string }; async function slackListProfileFields(identifier: string) { const response = await scalekit.actions.request({ connectionName: 'slack', identifier, method: 'GET', path: '/api/team.profile.get', }); const data = response.data as { ok: boolean; error?: string; profile?: { fields?: ProfileField[] } }; if (!data.ok) return { error: data.error }; const fields = data.profile?.fields ?? []; return { fields: fields.map(({ id, label, type }) => ({ id, label, type })) }; } console.log(listProfileFieldsTool.name, await slackListProfileFields('user_123')); ``` Pass the tool definition to your model with the built-in tools, and run the function when the model calls it. ## Check it worked The call returns the app's own response. For Slack, a `200` with `"ok": true` and the fields you asked for. Slack reports errors in the body with `"ok": false`, while most other APIs use a `4xx` status. ## Common problems ### `proxy not enabled for provider` The proxy is off for that connector. Contact [support@scalekit.com](mailto:support@scalekit.com) to turn it on. ### `authorization details are missing for connected account identifier` No active connected account matches this identifier and connection. Check both values, and [authorize the user](https://docs.scalekit.com/agentkit/tools/authorize/) if the account isn't `ACTIVE`. ### Node.js: `request()` throws a `401` on the first call Make another SDK call before your first `request()`, such as the account check in step 1. The Node.js client fetches its access token on its first regular SDK call, and `request()` then sends that token. In a long-running process, if `request()` later throws a `401` because the token expired, make another SDK call and retry. ### The app returns `401` or `403` The user's token is missing a scope the endpoint needs. Add the scope to the connection, then have the user authorize again. ### The app only accepts traffic from known IP addresses Add Scalekit's [outbound IP addresses](https://docs.scalekit.com/reference/outbound-ip-addresses/) for your region to the app's allowlist. ## Next - [Choose a framework](https://docs.scalekit.com/agentkit/examples/): Add your tools to an agent in LangChain, OpenAI, Anthropic and more. - [Create a connector](https://docs.scalekit.com/agentkit/bring-your-own-connector/overview/): Add an app that isn't in the catalog. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `GET` [Get a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/get-a-connected-account.md) - `GET` [Get a connected account's credentials](https://docs.scalekit.com/agentkit/reference/connected-accounts/get-connected-account-credentials.md) --- Source: https://docs.scalekit.com/agentkit/mcp/overview.md # How Virtual MCP servers work A Virtual MCP server exposes the tools you choose, for one user at a time, to any MCP client. How servers, session tokens and tool calls fit together. A **Virtual MCP server** is a URL that exposes tools you choose, from one or more connections, to any MCP client. You create one server per agent role, such as a support assistant, and all your users share it. Before each run, you mint a **session token** for one user. The agent connects to the server URL with that token and can call only the tools you chose, as that user. Use a Virtual MCP server when your agent already speaks MCP. If you build the agent loop yourself, you can call tools from your code instead: see [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/). ## Why use one - **The agent sees only the tools it needs.** A Gmail connection has dozens of tools. A summarizer needs one. The server exposes only the tools you list, so the agent can't call the rest. - **Smaller prompts.** Every tool on an MCP server goes into the model's context. Fewer tools means fewer tokens on every call. - **One definition, isolated users.** All users share the server's URL. Each run's session token is bound to one user and that user's connected accounts, so one user's run can't act with another user's credentials. ## How it works | When | You | What you get | | --- | --- | --- | | Once per agent role | [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/) with the connections and tools the agent needs | A server ID and a fixed `mcp_server_url` | | Before each run | Check the user's connected accounts are `ACTIVE`, then [mint a session token](https://docs.scalekit.com/agentkit/mcp/session-tokens/) | A bearer token for one user and one server | | During the run | Connect any MCP client to the URL with `Authorization: Bearer ` | The server's tools, called as that user | In the API and SDKs, a Virtual MCP server is an **MCP configuration**: the endpoints are under `/api/v1/mcp/configs`, and the SDK methods are `actions.mcp.create_config` in Python and `scalekit.actions.mcp.createConfig` in Node.js. ## Session tokens A session token authorizes one user on one server: - It lasts one hour unless you set `expiry`, which must be between 60 seconds and 24 hours. - It can't be refreshed or extended. Mint a new one instead. Minting a new token doesn't revoke earlier ones. - It only works on the server it was minted for. Any other server URL returns `401`. - Minting succeeds even if the user hasn't connected their accounts yet, so check the accounts first. [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/) covers when to mint for one-off, scheduled, concurrent and long-running agents. ## What the agent sees `tools/list` returns the tools you chose for the server. Tools from a connection the user hasn't authorized don't work until they do. By default, `tools/list` also includes a `connect_account` tool. When the user still needs to connect an app, the agent calls it to get an authorization link, and shows the link to the user. A tool call returns the same output as [`execute_tool`](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/#read-the-result): the app's response under `data`. If the app reports an error in a successful (`2xx`) response, that error is in `data`. If the app answers with an error status, the tool call returns an error result (`isError: true`) whose message starts with `tool execution failed`. ## Data flow 1. The MCP client sends a request to the server URL with the session token. 2. Scalekit checks the token against the server and the user, and finds the user's connected account for the tool's connection. 3. Scalekit calls the app's API with that account's credentials and returns the result to the client. Your agent never receives the user's OAuth token or API key. Responses pass through to the MCP client and aren't stored. See [what Scalekit stores](https://docs.scalekit.com/agentkit/security/#what-scalekit-stores). ## Next - [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/): Choose the tools and get the server URL. - [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/): Authorize a user's run and connect an MCP client. --- Source: https://docs.scalekit.com/agentkit/mcp/configure-mcp-server.md # Create a Virtual MCP server Create an AgentKit Virtual MCP server with only the tools an agent needs, then list, update or delete it, with examples in Python, Node.js and cURL. Create one Virtual MCP server for each agent role. It lists the connections and tools the agent can use, and gives you a fixed URL that every user of that agent shares. You do this once, not once per user. ## Before you start - A connection for each app the agent needs, in **AgentKit** > **Connections**. [Set up a connection](https://docs.scalekit.com/agentkit/connections/) if you haven't. These examples use `github-connect`, the GitHub connection new environments include. - A client set up as in the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/): `actions` in Python, `scalekit` in Node.js. For cURL, get an access token and keep it in `TOKEN`: ```bash TOKEN=$(curl -sS "$SCALEKIT_ENVIRONMENT_URL/oauth/token" \ -d grant_type=client_credentials \ -d client_id="$SCALEKIT_CLIENT_ID" \ -d client_secret="$SCALEKIT_CLIENT_SECRET" | jq -r .access_token) ``` 1. ## Choose the tools Pick the smallest set of tools the agent needs. Get exact names from [tool search or the scoped tool list](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/#find-the-right-tool), or from each connector's page in the [connector catalog](https://docs.scalekit.com/agentkit/connectors/). If you leave out `tools` for a connection, the server gets every tool that connection has when you create it. Tools added to the connector later aren't included. 2. ## Create the server Give the server a name that's unique in the environment, and list the tools for each connection. **Python** ```python from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping response = actions.mcp.create_config( name="repo-assistant", description="Reads the user's GitHub repositories and issues", connection_tool_mappings=[ McpConfigConnectionToolMapping( connection_name="github-connect", tools=["github_user_repos_list", "github_issues_list"], ), ], ) config_id = response.config.id mcp_server_url = response.config.mcp_server_url print(config_id, mcp_server_url) ``` **Node.js** ```ts const { config } = await scalekit.actions.mcp.createConfig({ name: 'repo-assistant', description: "Reads the user's GitHub repositories and issues", connectionToolMappings: [ { connectionName: 'github-connect', tools: ['github_user_repos_list', 'github_issues_list'] }, ], }); const configId = config!.id; const mcpServerUrl = config!.mcpServerUrl; console.log(configId, mcpServerUrl); ``` **cURL** ```bash curl -sS "$SCALEKIT_ENVIRONMENT_URL/api/v1/mcp/configs" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "repo-assistant", "description": "Reads the user'\''s GitHub repositories and issues", "connection_tool_mappings": [ {"connection_name": "github-connect", "tools": ["github_user_repos_list", "github_issues_list"]} ] }' | jq '.config | {id, mcp_server_url}' ``` 3. ## Save the ID and URL Store the server's `id` and `mcp_server_url` in your configuration. You pass the ID when you mint session tokens, and the URL to your agent. For cURL, keep them in `CONFIG_ID` and `MCP_SERVER_URL`. ## Check it worked List servers by name to see the new server and its URL: **Python** ```python response = actions.mcp.list_configs(filter_name="repo-assistant") for config in response.configs: print(config.id, config.name, config.mcp_server_url) ``` **Node.js** ```ts const { configs } = await scalekit.actions.mcp.listConfigs({ search: 'repo-assistant' }); for (const c of configs) console.log(c.id, c.name, c.mcpServerUrl); ``` **cURL** ```bash curl -sS -G "$SCALEKIT_ENVIRONMENT_URL/api/v1/mcp/configs" \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "filter.name=repo-assistant" | jq '.configs[] | {id, name, mcp_server_url}' ``` Then [mint a session token](https://docs.scalekit.com/agentkit/mcp/session-tokens/) and list the server's tools from an MCP client. ## Change or remove a server To change the tools, send the full list for each connection. It replaces the old list. **Python** ```python actions.mcp.update_config( config_id=config_id, connection_tool_mappings=[ McpConfigConnectionToolMapping( connection_name="github-connect", tools=["github_user_repos_list", "github_issues_list", "github_issue_get"], ), ], ) ``` **Node.js** ```ts await scalekit.actions.mcp.updateConfig({ configId, connectionToolMappings: [ { connectionName: 'github-connect', tools: ['github_user_repos_list', 'github_issues_list', 'github_issue_get'], }, ], }); ``` **cURL** ```bash curl -sS -X PUT "$SCALEKIT_ENVIRONMENT_URL/api/v1/mcp/configs/$CONFIG_ID" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "connection_tool_mappings": [ {"connection_name": "github-connect", "tools": ["github_user_repos_list", "github_issues_list", "github_issue_get"]} ] }' | jq '.config.connection_tool_mappings' ``` Change a server when no agent run is using it, because a run can lose a tool mid-conversation. For a larger change, create a new server and point your agent at its URL. Deleting a server stops its URL from working immediately, including for session tokens already minted. **Python** ```python actions.mcp.delete_config(config_id=config_id) ``` **Node.js** ```ts await scalekit.actions.mcp.deleteConfig(configId); ``` **cURL** ```bash curl -sS -X DELETE "$SCALEKIT_ENVIRONMENT_URL/api/v1/mcp/configs/$CONFIG_ID" \ -H "Authorization: Bearer $TOKEN" ``` ## Common problems ### `invalid tool ''` The tool name doesn't exist on that connection. Copy names from tool search or the scoped tool list. ### `connection '' not found` The connection name doesn't match **AgentKit** > **Connections** exactly. ### `config with name '' already exists` Server names are unique in an environment. Choose another name, or update the existing server. ## Next - [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/): Authorize a user's run and connect an MCP client. - [Choose a framework](https://docs.scalekit.com/agentkit/examples/): Connect the server to CrewAI, Mastra, Claude Managed Agents and more. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/create-a-virtual-mcp-server.md) - `GET` [List Virtual MCP servers](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/list-virtual-mcp-servers.md) - `PUT` [Update a Virtual MCP server](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/update-a-virtual-mcp-server.md) - `DELETE` [Delete a Virtual MCP server](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/delete-a-virtual-mcp-server.md) --- Source: https://docs.scalekit.com/agentkit/mcp/session-tokens.md # Mint session tokens Mint a session token so an agent can call a Virtual MCP server's tools as one user, and decide when to mint again for scheduled and long-running agents. A session token lets an MCP client call a Virtual MCP server's tools as one user. Mint one before each run: check that the user's connections are active, mint the token, and pass it to the agent as a bearer token. ## Before you start - A [Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/) and its ID. For cURL, keep the ID in `CONFIG_ID` and the URL in `MCP_SERVER_URL`. - The user's `identifier`: your app's ID for that user, the same value you passed when they authorized their connections. Use an ID that's stable and hard to guess, not an email address. [Choose an identifier](https://docs.scalekit.com/agentkit/concepts/#choose-an-identifier) explains why. - A client set up as in the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/): `actions` in Python, `scalekit` in Node.js. For cURL, get an access token and keep it in `TOKEN`: ```bash TOKEN=$(curl -sS "$SCALEKIT_ENVIRONMENT_URL/oauth/token" \ -d grant_type=client_credentials \ -d client_id="$SCALEKIT_CLIENT_ID" \ -d client_secret="$SCALEKIT_CLIENT_SECRET" | jq -r .access_token) ``` 1. ## Check the user's connections List the user's connected account for each connection on the server. For any that isn't `ACTIVE`, send the user its authorization link before the run. **Python** ```python response = actions.mcp.list_mcp_connected_accounts( config_id=config_id, identifier="user_123", include_auth_link=True, ) not_ready = [a for a in response.connected_accounts if a.connected_account_status != "ACTIVE"] for account in not_ready: print(f"{account.connection_name} is {account.connected_account_status}: {account.authentication_link}") ``` **Node.js** ```ts const { connectedAccounts } = await scalekit.actions.mcp.listConnectedAccounts({ configId, identifier: 'user_123', includeAuthLink: true, }); const notReady = connectedAccounts.filter((a) => a.connectedAccountStatus !== 'ACTIVE'); for (const a of notReady) { console.log(`${a.connectionName} is ${a.connectedAccountStatus}: ${a.authenticationLink}`); } ``` **cURL** ```bash curl -sS "$SCALEKIT_ENVIRONMENT_URL/api/v1/mcp/configs/$CONFIG_ID/connected_accounts" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"identifier": "user_123", "include_auth_link": true}' | jq '.connected_accounts[] | select(.connected_account_status != "ACTIVE") | {connection_name, connected_account_status, authentication_link}' ``` If the user has no account yet for a connection, this call creates one in `PENDING_AUTH` and returns its link. 2. ## Mint the token Set `expiry` a little above how long the run takes. It must be between 60 seconds and 24 hours, and defaults to one hour. **Python** ```python from datetime import timedelta session = actions.mcp.create_session_token( mcp_config_id=config_id, identifier="user_123", expiry=timedelta(minutes=30), ) session_token = session.token print(session.expires_at) ``` **Node.js** ```ts const session = await scalekit.actions.mcp.createSessionToken({ mcpConfigId: configId, identifier: 'user_123', expirySeconds: 30 * 60, }); const sessionToken = session.token; console.log(new Date(Number(session.expiresAt?.seconds) * 1000)); ``` **cURL** ```bash SESSION_TOKEN=$(curl -sS "$SCALEKIT_ENVIRONMENT_URL/api/v1/mcp/configs/$CONFIG_ID/tokens" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"identifier": "user_123", "expiry": "1800s"}' | jq -r .token) ``` 3. ## Connect the MCP client Pass the server URL and `Authorization: Bearer ` to your MCP client. Most frameworks take them as the server's URL and headers. With the MCP SDKs, or directly over HTTP: **Python** ```python import asyncio import httpx2 from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client async def list_tools(): headers = {"Authorization": f"Bearer {session_token}"} async with httpx2.AsyncClient(headers=headers, timeout=30) as http_client: async with streamable_http_client(mcp_server_url, http_client=http_client) as (read, write): async with ClientSession(read, write) as mcp_session: await mcp_session.initialize() result = await mcp_session.list_tools() print([tool.name for tool in result.tools]) asyncio.run(list_tools()) ``` **Node.js** ```ts import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl), { requestInit: { headers: { Authorization: `Bearer ${sessionToken}` } }, }); const mcpClient = new Client({ name: 'repo-assistant', version: '1.0.0' }); await mcpClient.connect(transport); const { tools } = await mcpClient.listTools(); console.log(tools.map((tool) => tool.name)); await mcpClient.close(); ``` **cURL** ```bash curl -sS "$MCP_SERVER_URL" \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' ``` The Python example uses `mcp` 2.x (`pip install "mcp>=2"`), and the Node.js example uses `@modelcontextprotocol/sdk`. For clients without an MCP SDK, [Connect any MCP client](https://docs.scalekit.com/agentkit/mcp/connect-any-client/) has the full HTTP contract, including `tools/call` and the response format. [Choose a framework](https://docs.scalekit.com/agentkit/examples/) shows the setup for CrewAI, Mastra and Claude Managed Agents. ## Choose when to mint A token can't be refreshed, so decide when your code mints a new one: | Your agent | When to mint | | --- | --- | | Runs once, for minutes | Once, right before the run. Set `expiry` above the longest run you expect. | | Chat session with a user | When the session starts. Mint again if the session outlives the token, or when a call returns `401`. | | Scheduled job, such as a daily briefing | At the start of each run. Don't store a token between runs. | | Many runs at once | One token per run. Tokens are independent, and minting a new one doesn't revoke the others. | | Long-running host or gateway | On a schedule, before `expires_at`. Give the host the new token, then reload it. A host with an expired token fails at its next tool call, not at startup. | A token lasts at most 24 hours, so a client that can't update its headers, such as a config file you edit by hand, stops working within a day. ## Check it worked `tools/list` returns the tools you chose for the server, and a tool call returns the app's response under `data`. ## Common problems ### `401` with `invalid or expired bearer token` The token expired, is for a different server, or was copied incompletely. Mint a new token for this server. ### `expiry must be between 60s and 24h` Set `expiry` between 60 seconds and 24 hours. In REST, send it in seconds, such as `"1800s"`. ### A tool call fails because the account isn't connected The user's account for that connection isn't `ACTIVE`. Run the check in step 1 and send the authorization link. If the server lists `connect_account`, the agent can get the link itself. ## Next - [Connect any MCP client](https://docs.scalekit.com/agentkit/mcp/connect-any-client/): The HTTP contract for clients that don't use an MCP SDK. - [Choose a framework](https://docs.scalekit.com/agentkit/examples/): Run an agent against the server in your framework. - [Verify users](https://docs.scalekit.com/agentkit/user-verification/): Confirm who authorized each connection before production. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `GET` [List tools](https://docs.scalekit.com/agentkit/reference/tools/list-tools.md) - `POST` [Check a user's connected accounts for a server](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/check-connected-accounts-for-a-server.md) - `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md) --- Source: https://docs.scalekit.com/agentkit/mcp/connect-any-client.md # Connect any MCP client The HTTP contract of an AgentKit Virtual MCP server for clients that don't use the Scalekit SDK: URL, bearer token, transport, requests, responses, token lifetime and errors. A Virtual MCP server is a standard MCP endpoint, so any client that speaks streamable HTTP can call it: an agent runtime, a hosted platform, your own HTTP code or `curl`. Your backend needs the Scalekit API only to mint session tokens; the client never needs the SDK. This page is the contract that client works against. ## The contract | | | | --- | --- | | Endpoint | The server's `mcp_server_url`, from [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). Send every request as `POST`. | | Authentication | `Authorization: Bearer `. A token is for one user on one server. [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/) shows how. | | Request headers | `Content-Type: application/json` and `Accept: application/json, text/event-stream` | | Transport | MCP streamable HTTP. Each response is a server-sent event stream with one `message` event, whose `data` line is the JSON-RPC response. | | Session | None to manage. `tools/list` and `tools/call` work without an `initialize` call first, and the server sends no `Mcp-Session-Id`. Clients that do send `initialize` and `notifications/initialized` get the normal responses. | | Methods | `initialize`, `tools/list` and `tools/call`. `GET` on the URL returns `405`: the server never opens a stream of its own. | | Tools | The tools you chose for the server. By default, also `connect_account`: when the user still needs to connect an app, it returns an authorization link for that connection. | | Token lifetime | 1 hour by default, from 60 seconds to 24 hours. A token can't be refreshed: mint a new one. Tokens are independent, so minting one doesn't revoke the others. | ## Call the server 1. ## Mint a session token Your backend mints the token for the user, as in [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/#mint-the-token), and hands the client the server URL and the token. Keep them in `MCP_SERVER_URL` and `SESSION_TOKEN`. 2. ## List the tools ```bash curl -sS "$MCP_SERVER_URL" \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' ``` The response is one event. Its `data` line holds each tool's `name`, `description` and `inputSchema`: ```text event: message data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"connect_account","description":"Connect (or RE-connect) the user's account…","inputSchema":{…}},{"name":"github_user_repos_list",…}]}} ``` 3. ## Call a tool Send the tool's `name` and `arguments` that match its `inputSchema`: ```bash curl -sS "$MCP_SERVER_URL" \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "github_user_repos_list", "arguments": {"per_page": 5}} }' ``` The result is MCP text content. Its `text` is a JSON string, and the app's response is under `data`: ```text event: message data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\n \"data\": [ … ]\n}"}]}} ``` To read it in a shell, keep the `data:` line and parse the text twice: ```bash … | sed -n 's/^data: //p' | jq -r '.result.content[0].text' | jq '.data' ``` ## Connect a user's account from the agent If a tool call fails because the user hasn't authorized that connection, or tools you expect are missing from `tools/list`, call `connect_account`: 1. Call it with no arguments. The response lists `connections_requiring_authorization` and `connections_already_authorized`, each with a status and a reason. Nothing is created, so this is always safe. 2. Call it again with `connection_name` set to one of the connections that needs authorization. The response has a time-limited `magic_link_url`. 3. Show the link to the user. The agent shouldn't open it itself. Once the user signs in, retry the tool call with the same token. A connection that uses an organization-wide credential has no user step: the response says `auth_mode` is `ORG_WIDE` and has no link. ## Renew the token The client can't renew a token. Before it expires, your backend mints a new one and the client switches to it. A host with an expired token fails at its next request, not at startup. [Choose when to mint](https://docs.scalekit.com/agentkit/mcp/session-tokens/#choose-when-to-mint) covers single runs, chat sessions, scheduled jobs, concurrent runs and long-running hosts. ## Errors A rejected token gets HTTP `401` with `{"error":"unauthorized","error_description":"invalid or expired bearer token"}`. Errors inside a request that got through come back as JSON-RPC errors in the `message` event. | What you see | Cause | Fix | | --- | --- | --- | | `401`, `invalid or expired bearer token` | No `Authorization` header, an expired token, a token minted for another server, or a token that was copied incompletely | Mint a new token for this server and user. | | `405` | A `GET` request | Send JSON-RPC requests as `POST`. | | JSON-RPC error `-32602` with `unknown tool` | The tool isn't on this server, or the name is misspelled | Use a name from `tools/list`. To add a tool, [update the server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/#change-or-remove-a-server). | | A tool call fails, or a connection's tools are missing | The user hasn't authorized that connection | Call `connect_account`, as above. | ## Next - [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/): Mint the token on your backend, and decide when to mint again. - [Choose a framework](https://docs.scalekit.com/agentkit/examples/): Set up CrewAI, Mastra or Claude Managed Agents against the server. --- Source: https://docs.scalekit.com/agentkit/examples.md # Choose a framework Give your agent Gmail, Slack and other tools as each user: complete AgentKit examples for LangChain, Google ADK, OpenAI, Mastra and more frameworks. Each framework example builds a working agent that reads a user's Gmail inbox with Scalekit-authenticated tools. Claude Managed Agents also creates Google Calendar events, and Hermes works with GitHub, Gmail, Slack and other apps. ## No agent loop to build These platforms manage the agent harness for you. Pass a Scalekit skill or MCP URL, describe the task, and the platform handles tool discovery, execution, and session state. - [Claude Managed Agents](https://docs.scalekit.com/agentkit/examples/claude-managed-agents/): Anthropic runs the agent loop. Pass a Scalekit MCP URL, describe a task, and Claude handles tool discovery, execution, and retries. - [Hermes](https://docs.scalekit.com/agentkit/hermes/): Nous Research agent runtime. Install a Scalekit skill and act as a named user in Gmail, Slack, and other apps. - [Hermes example repo](https://github.com/scalekit-developers/hermes-agentkit-example): Companion repo for the Hermes how-to. Install hermes-delegated-auth and run one GitHub read as an opaque identifier. ## Build your own agent loop These integrations give you full control. Fetch Scalekit tool schemas, wire them into your framework, and run the tool-use loop yourself. - **[LangChain](https://docs.scalekit.com/agentkit/examples/langchain/)** (Python, SDK, native adapter): Scalekit returns native LangChain tool objects. No schema reshaping needed. - **[Google ADK](https://docs.scalekit.com/agentkit/examples/google-adk/)** (Python, SDK, native adapter): Scalekit returns native ADK tool objects. No schema reshaping needed. - **[Anthropic](https://docs.scalekit.com/agentkit/examples/anthropic/)** (Python and Node.js, SDK, direct): Tool schemas use `input_schema`, which matches Anthropic's format exactly. - **[OpenAI](https://docs.scalekit.com/agentkit/examples/openai/)** (Python and Node.js, SDK, direct): Rename `input_schema` to `parameters` to match OpenAI's function format. - **[Vercel AI SDK](https://docs.scalekit.com/agentkit/examples/vercel-ai/)** (Node.js, SDK, `tool()` helper): Wrap tools with `tool()` and `jsonSchema()`. No manual schema conversion needed. - **[CrewAI](https://docs.scalekit.com/agentkit/examples/crewai/)** (Python, MCP): `MCPServerAdapter` connects to a Scalekit MCP URL. Tool discovery is automatic. - **[Mastra](https://docs.scalekit.com/agentkit/examples/mastra/)** (Node.js, MCP): Native MCP support via `@mastra/mcp`. Tool discovery is automatic. ## Working examples on GitHub ### [Connect LangChain agents to Gmail](https://github.com/scalekit-inc/sample-langchain-agent) Securely connect a LangChain agent to Gmail using Scalekit for authentication. Python example for tool authorization. ### [Connect Google GenAI agents to Gmail](https://github.com/scalekit-inc/google-adk-agent-example) Build a Google ADK agent that securely accesses Gmail tools. Python example demonstrating Scalekit auth integration. ### [Connect agents to Slack tools](https://github.com/scalekit-inc/python-connect-demos/tree/main/direct) Authorize Python agents to use Slack tools with Scalekit. Direct integration example for secure tool access. ### [Connect CrewAI agents to Gmail](https://github.com/scalekit-developers/crewai-scalekit-example) Multi-agent email triage crew using CrewAI with Scalekit-authenticated Gmail tools via MCP. ### [Meeting prep agent](https://github.com/scalekit-inc/meeting-prep-agent-example) Pulls context from Google Cal, Gmail, HubSpot, and Slack before each external meeting. Delivers a structured brief in under 60 seconds using delegated user identity. ### [Browse all agent auth examples](https://github.com/scalekit-developers/agent-auth-examples) A curated collection of working examples showing how to build agents that authenticate and access tools using Scalekit. --- Source: https://docs.scalekit.com/agentkit/examples/langchain.md # LangChain Build a LangChain agent with Scalekit-authenticated Gmail tools. Scalekit returns native LangChain tool objects; no schema reshaping needed. Build a LangChain agent that reads a user's Gmail inbox. Scalekit handles OAuth, token storage, and returns tools in native LangChain format. Your agent code needs no Scalekit-specific logic beyond initialization. Full code on GitHub ## Before you start - Create a Gmail connection in **AgentKit** > **Connections**. The samples use the connection name `gmail`, so change it if yours differs. See [Set up a connection](https://docs.scalekit.com/agentkit/connections/). - Have the `.env` file from the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/), and add your OpenAI API key as `OPENAI_API_KEY` to it. - The samples load `.env` with `load_dotenv()` from `python-dotenv`. ## Install ```sh pip install scalekit-sdk-python python-dotenv langchain-openai ``` ## Initialize ```python import os import scalekit.client from dotenv import load_dotenv load_dotenv() scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) actions = scalekit_client.actions ``` ## Connect the user to Gmail ```python # Connect the user's Gmail account, and wait until it's ACTIVE before calling tools connection_name = "gmail" identifier = "user_123" # your app's unique user ID response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=connection_name, identifier=identifier ) print("Authorize Gmail:", link.link) input("Press Enter after authorizing...") # Fetch the account again to pick up the new status response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": raise RuntimeError( f"Gmail is {response.connected_account.status}, not ACTIVE. Authorize it and run again." ) ``` See [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) for production auth handling. ## Build and run the agent `actions.langchain.get_tools()` returns native `StructuredTool` objects. Bind them to your LLM and run the tool-calling loop: ```python from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage, ToolMessage tools = actions.langchain.get_tools( identifier="user_123", connection_names=["gmail"], page_size=100, # avoid missing tools when a connector has more than the default page ) tool_map = {t.name: t for t in tools} llm = ChatOpenAI(model="gpt-4o").bind_tools(tools) messages = [HumanMessage("Fetch my last 5 unread emails and summarize them")] while True: response = llm.invoke(messages) messages.append(response) if not response.tool_calls: print(response.content) break for tc in response.tool_calls: result = tool_map[tc["name"]].invoke(tc["args"]) messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"])) ``` > note: Multiple Gmail accounts > > If a user has multiple Gmail connections, pass the specific `connection_names` value from your Scalekit dashboard to scope tools to the right one. ## Use MCP instead LangChain connects to MCP servers with `langchain-mcp-adapters`. Pass the Virtual MCP server URL and a session token for this user: ```sh pip install "langchain-mcp-adapters>=0.3,<1" ``` ```python import asyncio import os from datetime import timedelta from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage, ToolMessage # Returned by create_config when you created the Virtual MCP server config_id = os.environ["SCALEKIT_MCP_CONFIG_ID"] mcp_url = os.environ["SCALEKIT_MCP_SERVER_URL"] # Mint a fresh session token for this user before each agent run mcp_token = actions.mcp.create_session_token( mcp_config_id=config_id, identifier="user_123", expiry=timedelta(hours=1), ).token async def run(): client = MultiServerMCPClient( { "scalekit": { "transport": "streamable_http", "url": mcp_url, "headers": {"Authorization": f"Bearer {mcp_token}"}, } } ) tools = await client.get_tools() tool_map = {t.name: t for t in tools} llm = ChatOpenAI(model="gpt-4o").bind_tools(tools) messages = [HumanMessage("Fetch my last 5 unread emails and summarize them")] while True: response = await llm.ainvoke(messages) messages.append(response) if not response.tool_calls: print(response.content) break for tc in response.tool_calls: result = await tool_map[tc["name"]].ainvoke(tc["args"]) messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"])) asyncio.run(run()) ``` See [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/) and [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/) to create the server, check the user's connections and mint a token. ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md) --- Source: https://docs.scalekit.com/agentkit/examples/google-adk.md # Google ADK Build a Google ADK agent with Scalekit-authenticated Gmail tools. Scalekit returns native ADK tool objects; no schema reshaping needed. Build a Google ADK agent that reads a user's Gmail inbox. Scalekit handles OAuth, token storage, and returns tools as native ADK tool objects compatible with any ADK agent. Full code on GitHub ## Before you start - Create a Gmail connection in **AgentKit** > **Connections**. The samples use the connection name `gmail`, so change it if yours differs. See [Set up a connection](https://docs.scalekit.com/agentkit/connections/). - Have the `.env` file from the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/), and add your Gemini API key as `GOOGLE_API_KEY` to it. - The samples load `.env` with `load_dotenv()` from `python-dotenv`. ## Install ```sh pip install scalekit-sdk-python python-dotenv "google-adk>=2,<3" ``` ## Initialize ```python import os import asyncio import scalekit.client from dotenv import load_dotenv load_dotenv() scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) actions = scalekit_client.actions ``` ## Connect the user to Gmail ```python # Connect the user's Gmail account, and wait until it's ACTIVE before calling tools connection_name = "gmail" identifier = "user_123" # your app's unique user ID response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=connection_name, identifier=identifier ) print("Authorize Gmail:", link.link) input("Press Enter after authorizing...") # Fetch the account again to pick up the new status response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": raise RuntimeError( f"Gmail is {response.connected_account.status}, not ACTIVE. Authorize it and run again." ) ``` See [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) for production auth handling. ## Build and run the agent `actions.google.get_tools()` returns native ADK tool objects. Pass them directly to a Google ADK `Agent`: ```python from google.adk.agents import Agent from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService from google.genai import types tools = actions.google.get_tools( identifier="user_123", connection_names=["gmail"], page_size=100, # avoid missing tools when a connector has more than the default page ) agent = Agent( name="gmail_assistant", model="gemini-2.0-flash", instruction="You are a helpful Gmail assistant.", tools=tools, ) async def main(): session_service = InMemorySessionService() runner = Runner(agent=agent, app_name="gmail_app", session_service=session_service) session = await session_service.create_session(app_name="gmail_app", user_id="user_123") message = types.Content( role="user", parts=[types.Part(text="Fetch my last 5 unread emails and summarize them")], ) async for event in runner.run_async( user_id="user_123", session_id=session.id, new_message=message, ): if event.is_final_response() and event.content and event.content.parts: print(event.content.parts[0].text) asyncio.run(main()) ``` > note: Multiple Gmail accounts > > If a user has multiple Gmail connections, pass the specific `connection_names` value from your Scalekit dashboard to scope tools to the right one. ## Use MCP instead Google ADK connects to MCP servers with `McpToolset`. Pass the Virtual MCP server URL and a session token for this user: ```python import os from datetime import timedelta from google.adk.agents import Agent from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams # Returned by create_config when you created the Virtual MCP server config_id = os.environ["SCALEKIT_MCP_CONFIG_ID"] mcp_url = os.environ["SCALEKIT_MCP_SERVER_URL"] # Mint a fresh session token for this user before each agent run mcp_token = actions.mcp.create_session_token( mcp_config_id=config_id, identifier="user_123", expiry=timedelta(hours=1), ).token agent = Agent( name="gmail_assistant", model="gemini-2.0-flash", instruction="You are a helpful Gmail assistant.", tools=[ McpToolset( connection_params=StreamableHTTPConnectionParams( url=mcp_url, headers={"Authorization": f"Bearer {mcp_token}"}, ) ) ], ) ``` Run this agent with the same `Runner` loop as above. See [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/) and [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/) to create the server, check the user's connections and mint a token. ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md) --- Source: https://docs.scalekit.com/agentkit/examples/openai.md # OpenAI Build an OpenAI agent with Scalekit-authenticated tools. Convert Scalekit's tool schemas to OpenAI's function calling format in one step. Build an agent using OpenAI's GPT models that reads a user's Gmail inbox. Scalekit's tool schemas use `input_schema`: rename it to `parameters` and wrap it in OpenAI's function format. ## Before you start - Create a Gmail connection in **AgentKit** > **Connections**. The samples use the connection name `gmail`, so change it if yours differs. See [Set up a connection](https://docs.scalekit.com/agentkit/connections/). - Have the `.env` file from the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/), and add your OpenAI API key as `OPENAI_API_KEY` to it. - The samples load `.env` with `load_dotenv()` from `python-dotenv` in Python, and `import 'dotenv/config'` in Node.js. ## Install **Python** ```sh pip install scalekit-sdk-python python-dotenv openai ``` **Node.js** ```sh npm install @scalekit-sdk/node openai dotenv ``` ## Initialize **Python** ```python import os, json import scalekit.client from dotenv import load_dotenv from openai import OpenAI from google.protobuf.json_format import MessageToDict load_dotenv() scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) actions = scalekit_client.actions client = OpenAI() ``` **Node.js** ```typescript import 'dotenv/config'; import { ScalekitClient } from '@scalekit-sdk/node'; import OpenAI from 'openai'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ); const openai = new OpenAI(); ``` ## Connect the user to Gmail **Python** ```python # Connect the user's Gmail account, and wait until it's ACTIVE before calling tools connection_name = "gmail" identifier = "user_123" # your app's unique user ID response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=connection_name, identifier=identifier ) print("Authorize Gmail:", link.link) input("Press Enter after authorizing...") # Fetch the account again to pick up the new status response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": raise RuntimeError( f"Gmail is {response.connected_account.status}, not ACTIVE. Authorize it and run again." ) ``` **Node.js** ```typescript import { createInterface } from 'node:readline/promises'; import { ConnectorStatus } from '@scalekit-sdk/node'; // Connect the user's Gmail account, and wait until it's ACTIVE before calling tools const connectionName = 'gmail'; const identifier = 'user_123'; // your app's unique user ID let { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier, }); if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName, identifier }); console.log('Authorize Gmail:', link); const rl = createInterface({ input: process.stdin, output: process.stdout }); await rl.question('Press Enter after authorizing...'); rl.close(); // Fetch the account again to pick up the new status ({ connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier, })); } if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { throw new Error(`Gmail is not ACTIVE (status ${connectedAccount?.status}). Authorize it and run again.`); } ``` See [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) for production auth handling. ## Run the agent Fetch tools scoped to this user, convert to OpenAI's function format, then run the tool-calling loop. [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/) explains the scoped tool list, paging past 100 tools, and how to read `data` when the app returns an error. **Python** ```python # Fetch and convert tools to OpenAI format scoped_response, _ = actions.tools.list_scoped_tools( identifier="user_123", filter={"connection_names": ["gmail"]}, page_size=100, # fetch beyond the default page so no connector tools are missed ) llm_tools = [ { "type": "function", "function": { "name": MessageToDict(t.tool).get("definition", {}).get("name"), "description": MessageToDict(t.tool).get("definition", {}).get("description", ""), "parameters": MessageToDict(t.tool).get("definition", {}).get("input_schema", {}), }, } for t in scoped_response.tools ] # Run the agent loop messages = [{"role": "user", "content": "Fetch my last 5 unread emails and summarize them"}] while True: response = client.chat.completions.create( model="gpt-4o", tools=llm_tools, messages=messages, ) message = response.choices[0].message if not message.tool_calls: print(message.content) break messages.append(message) for tc in message.tool_calls: result = actions.execute_tool( tool_name=tc.function.name, identifier="user_123", connection_name="gmail", tool_input=json.loads(tc.function.arguments), ) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": str(result.data), }) ``` **Node.js** ```typescript // Fetch and convert tools to OpenAI format const { tools } = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: ['gmail'] }, pageSize: 100, // fetch beyond the default page so no connector tools are missed }); // Tool definitions are typed as generic JSON, so read each field explicitly const definitions = tools.flatMap((t) => (t.tool?.definition ? [t.tool.definition] : [])); const llmTools: OpenAI.ChatCompletionTool[] = definitions.map((definition) => ({ type: 'function', function: { name: String(definition.name), description: String(definition.description ?? ''), parameters: definition.input_schema as OpenAI.FunctionParameters, }, })); // Run the agent loop const messages: OpenAI.ChatCompletionMessageParam[] = [ { role: 'user', content: 'Fetch my last 5 unread emails and summarize them' }, ]; while (true) { const response = await openai.chat.completions.create({ model: 'gpt-4o', tools: llmTools, messages, }); const message = response.choices[0].message; if (!message.tool_calls?.length) { console.log(message.content); break; } messages.push(message); for (const tc of message.tool_calls) { if (tc.type !== 'function') continue; // only function tools are registered const result = await scalekit.actions.executeTool({ toolName: tc.function.name, identifier: 'user_123', connector: 'gmail', toolInput: JSON.parse(tc.function.arguments), }); messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result.data) }); } } ``` ## Use the Responses API OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) is a stateful alternative to Chat Completions. Instead of managing conversation history yourself, you pass `previous_response_id` to continue a session. It takes tools in a flat shape, `{type, name, description, parameters}`, without the `function` wrapper that Chat Completions uses, so convert the tools first. > note: Using a proxy or gateway > > The Responses API works with an OpenAI API key. Many OpenAI-compatible proxies and gateways support only Chat Completions, so check yours before you switch, or use the Chat Completions loop above. **Python** ```python # Responses API tools are flat: no "function" wrapper. # strict=False because Scalekit schemas don't follow strict mode's rules. response_tools = [{"type": "function", **t["function"], "strict": False} for t in llm_tools] response = client.responses.create( model="gpt-4o", input="Fetch my last 5 unread emails and summarize them", tools=response_tools, ) while any(item.type == "function_call" for item in response.output): tool_results = [ { "type": "function_call_output", "call_id": item.call_id, "output": str(actions.execute_tool( tool_name=item.name, identifier="user_123", connection_name="gmail", tool_input=json.loads(item.arguments), ).data), } for item in response.output if item.type == "function_call" ] response = client.responses.create( model="gpt-4o", previous_response_id=response.id, input=tool_results, tools=response_tools, ) for item in response.output: if item.type == "message": print(item.content[0].text) ``` **Node.js** ```typescript // Responses API tools are flat: no `function` wrapper. // strict: false because Scalekit schemas don't follow strict mode's rules. const responseTools: OpenAI.Responses.FunctionTool[] = definitions.map((definition) => ({ type: 'function', name: String(definition.name), description: String(definition.description ?? ''), parameters: definition.input_schema as Record, strict: false, })); let response = await openai.responses.create({ model: 'gpt-4o', input: 'Fetch my last 5 unread emails and summarize them', tools: responseTools, }); while (response.output.some(item => item.type === 'function_call')) { const toolResults = await Promise.all( response.output .filter(item => item.type === 'function_call') .map(async item => { const result = await scalekit.actions.executeTool({ toolName: item.name, identifier: 'user_123', connector: 'gmail', toolInput: JSON.parse(item.arguments), }); return { type: 'function_call_output' as const, call_id: item.call_id, output: JSON.stringify(result.data), }; }) ); response = await openai.responses.create({ model: 'gpt-4o', previous_response_id: response.id, input: toolResults, tools: responseTools, }); } const message = response.output.find(item => item.type === 'message'); if (message?.type === 'message') { for (const part of message.content) { if (part.type === 'output_text') console.log(part.text); } } ``` ## Use MCP instead If you prefer the MCP approach, connect your OpenAI agent via the [Vercel AI SDK + MCP](https://docs.scalekit.com/agentkit/examples/vercel-ai#use-mcp-instead) or LangChain's MCP client with a Scalekit-generated URL. See [Virtual MCP servers](https://docs.scalekit.com/agentkit/mcp/overview/) for the URL setup. ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) - `GET` [List a user's scoped tools](https://docs.scalekit.com/agentkit/reference/tools/list-scoped-tools.md) --- Source: https://docs.scalekit.com/agentkit/examples/anthropic.md # Anthropic Build an Anthropic agent with Scalekit-authenticated tools. Scalekit returns tool schemas in Anthropic's native format; no conversion needed. Build an agent using Anthropic's Claude that reads a user's Gmail inbox. Scalekit returns tool schemas with `input_schema`, the exact format Anthropic's tool use API expects. ## Before you start - Create a Gmail connection in **AgentKit** > **Connections**. The samples use the connection name `gmail`, so change it if yours differs. See [Set up a connection](https://docs.scalekit.com/agentkit/connections/). - Have the `.env` file from the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/), and add your Anthropic API key as `ANTHROPIC_API_KEY` to it. - The samples load `.env` with `load_dotenv()` from `python-dotenv` in Python, and `import 'dotenv/config'` in Node.js. ## Install **Python** ```sh pip install scalekit-sdk-python python-dotenv anthropic ``` **Node.js** ```sh npm install @scalekit-sdk/node @anthropic-ai/sdk dotenv ``` For Node.js, save the code in a `.mts` file, such as `agent.mts`, and run it with `npx tsx agent.mts`. The `.mts` extension makes the file an ES module, so it can use top-level `await`. ## Initialize **Python** ```python import os import scalekit.client from dotenv import load_dotenv import anthropic from google.protobuf.json_format import MessageToDict load_dotenv() scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) actions = scalekit_client.actions client = anthropic.Anthropic() ``` **Node.js** ```typescript import 'dotenv/config'; import { ScalekitClient } from '@scalekit-sdk/node'; import Anthropic from '@anthropic-ai/sdk'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ); const anthropic = new Anthropic(); ``` ## Connect the user to Gmail **Python** ```python # Connect the user's Gmail account, and wait until it's ACTIVE before calling tools connection_name = "gmail" identifier = "user_123" # your app's unique user ID response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=connection_name, identifier=identifier ) print("Authorize Gmail:", link.link) input("Press Enter after authorizing...") # Fetch the account again to pick up the new status response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": raise RuntimeError( f"Gmail is {response.connected_account.status}, not ACTIVE. Authorize it and run again." ) ``` **Node.js** ```typescript import { createInterface } from 'node:readline/promises'; import { ConnectorStatus } from '@scalekit-sdk/node'; // Connect the user's Gmail account, and wait until it's ACTIVE before calling tools const connectionName = 'gmail'; const identifier = 'user_123'; // your app's unique user ID let { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier, }); if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName, identifier }); console.log('Authorize Gmail:', link); const rl = createInterface({ input: process.stdin, output: process.stdout }); await rl.question('Press Enter after authorizing...'); rl.close(); // Fetch the account again to pick up the new status ({ connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier, })); } if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { throw new Error(`Gmail is not ACTIVE (status ${connectedAccount?.status}). Authorize it and run again.`); } ``` See [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) for production auth handling. ## Run the agent Fetch tools scoped to this user, then run the full Claude tool-use loop. [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/) explains the scoped tool list, paging past 100 tools, and how to read `data` when the app returns an error. **Python** ```python # Fetch tools scoped to this user scoped_response, _ = actions.tools.list_scoped_tools( identifier="user_123", filter={"connection_names": ["gmail"]}, page_size=100, # fetch beyond the default page so no connector tools are missed ) llm_tools = [ { "name": MessageToDict(t.tool).get("definition", {}).get("name"), "description": MessageToDict(t.tool).get("definition", {}).get("description", ""), "input_schema": MessageToDict(t.tool).get("definition", {}).get("input_schema", {}), } for t in scoped_response.tools ] # Run the agent loop messages = [{"role": "user", "content": "Fetch my last 5 unread emails and summarize them"}] def ask(): return client.messages.create( model="claude-sonnet-4-6", max_tokens=4096, tools=llm_tools, messages=messages, ) response = ask() while response.stop_reason == "tool_use": tool_results = [] for block in response.content: if block.type == "tool_use": result = actions.execute_tool( tool_name=block.name, identifier="user_123", connection_name="gmail", tool_input=block.input, ) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": str(result.data), }) messages.append({"role": "assistant", "content": response.content}) messages.append({"role": "user", "content": tool_results}) response = ask() # end_turn is the normal finish. Others, such as max_tokens or refusal, end early. if response.stop_reason != "end_turn": print(f"Stopped early: {response.stop_reason}") print("".join(block.text for block in response.content if block.type == "text")) ``` **Node.js** ```typescript // Fetch tools scoped to this user const { tools } = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: ['gmail'] }, pageSize: 100, // fetch beyond the default page so no connector tools are missed }); // Tool definitions are typed as generic JSON, so read each field explicitly const llmTools: Anthropic.Tool[] = tools.flatMap((t) => { const definition = t.tool?.definition; if (!definition) return []; return [{ name: String(definition.name), description: String(definition.description ?? ''), input_schema: definition.input_schema as Anthropic.Tool.InputSchema, }]; }); // Run the agent loop const messages: Anthropic.MessageParam[] = [ { role: 'user', content: 'Fetch my last 5 unread emails and summarize them' }, ]; const ask = () => anthropic.messages.create({ model: 'claude-sonnet-4-6', max_tokens: 4096, tools: llmTools, messages, }); let response = await ask(); while (response.stop_reason === 'tool_use') { const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const block of response.content) { if (block.type === 'tool_use') { const result = await scalekit.actions.executeTool({ toolName: block.name, identifier: 'user_123', connector: 'gmail', toolInput: block.input as Record, }); toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(result.data) }); } } messages.push({ role: 'assistant', content: response.content }); messages.push({ role: 'user', content: toolResults }); response = await ask(); } // end_turn is the normal finish. Others, such as max_tokens or refusal, end early. if (response.stop_reason !== 'end_turn') console.log(`Stopped early: ${response.stop_reason}`); for (const block of response.content) { if (block.type === 'text') console.log(block.text); } ``` ## Use MCP instead The Messages API can connect to a Scalekit Virtual MCP server through Anthropic's [MCP connector](https://docs.anthropic.com/en/docs/agents-and-tools/mcp-connector) (beta). Anthropic's API calls the MCP server for you, so there's no tool loop to write. Pass the server URL and a session token for this user. **Python** ```python from datetime import timedelta # Returned by create_config when you created the Virtual MCP server config_id = os.environ["SCALEKIT_MCP_CONFIG_ID"] mcp_url = os.environ["SCALEKIT_MCP_SERVER_URL"] # Mint a fresh session token for this user before each agent run mcp_token = actions.mcp.create_session_token( mcp_config_id=config_id, identifier="user_123", expiry=timedelta(hours=1), ).token response = client.beta.messages.create( model="claude-sonnet-4-6", max_tokens=4096, messages=[{"role": "user", "content": "Fetch my last 5 unread emails and summarize them"}], mcp_servers=[ {"type": "url", "url": mcp_url, "name": "scalekit", "authorization_token": mcp_token} ], tools=[{"type": "mcp_toolset", "mcp_server_name": "scalekit"}], betas=["mcp-client-2025-11-20"], ) print("".join(block.text for block in response.content if block.type == "text")) ``` **Node.js** ```typescript // Mint the token on your backend with scalekit.actions.mcp.createSessionToken // (see Mint session tokens) and pass it in here. const mcpUrl = process.env.SCALEKIT_MCP_SERVER_URL!; // mcp_server_url from your Virtual MCP server const mcpToken = process.env.SCALEKIT_MCP_SESSION_TOKEN!; // minted on your backend for this user const response = await anthropic.beta.messages.create({ model: 'claude-sonnet-4-6', max_tokens: 4096, messages: [{ role: 'user', content: 'Fetch my last 5 unread emails and summarize them' }], mcp_servers: [{ type: 'url', url: mcpUrl, name: 'scalekit', authorization_token: mcpToken }], tools: [{ type: 'mcp_toolset', mcp_server_name: 'scalekit' }], betas: ['mcp-client-2025-11-20'], }); for (const block of response.content) { if (block.type === 'text') console.log(block.text); } ``` Other MCP hosts, such as Claude Code, can connect to the same URL. Send the session token as an `Authorization: Bearer` header, and mint a new token when it expires (at most 24 hours). See [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/) and [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/) to create the server, check the user's connections and mint a token. ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) - `GET` [List a user's scoped tools](https://docs.scalekit.com/agentkit/reference/tools/list-scoped-tools.md) - `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md) --- Source: https://docs.scalekit.com/agentkit/examples/vercel-ai.md # Vercel AI SDK Build a Vercel AI SDK agent that reads Gmail as the user with AgentKit: wrap Scalekit tools with tool() and jsonSchema(), or connect to a Virtual MCP server. Build an agent using the Vercel AI SDK that reads a user's Gmail inbox. Wrap each Scalekit tool with `tool()` from the `ai` package, and pass its JSON Schema to the model with `jsonSchema()`. ## Before you start - Create a Gmail connection in **AgentKit** > **Connections**. The samples use the connection name `gmail`, so change it if yours differs. See [Set up a connection](https://docs.scalekit.com/agentkit/connections/). - Have the `.env` file from the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/), and add your OpenAI API key as `OPENAI_API_KEY` to it. - The samples load `.env` with `import 'dotenv/config'`. ## Install ```sh npm install @scalekit-sdk/node ai@7 @ai-sdk/openai@4 dotenv ``` ## Initialize ```typescript import 'dotenv/config'; import { ScalekitClient } from '@scalekit-sdk/node'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ); ``` ## Connect the user to Gmail ```typescript import { createInterface } from 'node:readline/promises'; import { ConnectorStatus } from '@scalekit-sdk/node'; // Connect the user's Gmail account, and wait until it's ACTIVE before calling tools const connectionName = 'gmail'; const identifier = 'user_123'; // your app's unique user ID let { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier, }); if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName, identifier }); console.log('Authorize Gmail:', link); const rl = createInterface({ input: process.stdin, output: process.stdout }); await rl.question('Press Enter after authorizing...'); rl.close(); // Fetch the account again to pick up the new status ({ connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier, })); } if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { throw new Error(`Gmail is not ACTIVE (status ${connectedAccount?.status}). Authorize it and run again.`); } ``` See [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) for production auth handling. ## Run the agent Fetch the tools scoped to this user, wrap each one with the AI SDK's `tool()` helper, then let `generateText` run the tool-calling loop. [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/) explains the scoped tool list, paging past 100 tools, and how to read `data` when the app returns an error. ```typescript import { generateText, jsonSchema, stepCountIs, tool } from 'ai'; import { openai } from '@ai-sdk/openai'; const { tools: scopedTools } = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: ['gmail'] }, pageSize: 100, // fetch beyond the default page so no connector tools are missed }); // Tool definitions are typed as generic JSON, so read each field explicitly const definitions = scopedTools.flatMap((t) => (t.tool?.definition ? [t.tool.definition] : [])); const tools = Object.fromEntries( definitions.map((definition) => { const name = String(definition.name); const schema = definition.input_schema ?? { type: 'object', properties: {} }; return [ name, tool({ description: String(definition.description ?? ''), inputSchema: jsonSchema>(schema as Parameters[0]), execute: async (args) => { const result = await scalekit.actions.executeTool({ toolName: name, identifier: 'user_123', connector: 'gmail', toolInput: args, }); return result.data; }, }), ]; }), ); const { text } = await generateText({ model: openai('gpt-4o'), tools, stopWhen: stepCountIs(5), prompt: 'Fetch my last 5 unread emails and summarize them', }); console.log(text); ``` ## Use MCP instead The Vercel AI SDK connects to MCP servers with `createMCPClient` from `@ai-sdk/mcp`. Pass the Virtual MCP server URL and a session token, and the client loads the server's tools for you. ```sh npm install @ai-sdk/mcp@2 ``` Mint the session token on your backend for each agent session with `scalekit.actions.mcp.createSessionToken`, as shown in [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/#mint-the-token), and pass it to this code. ```typescript import { createMCPClient } from '@ai-sdk/mcp'; import { generateText, stepCountIs } from 'ai'; import { openai } from '@ai-sdk/openai'; const mcpUrl = process.env.SCALEKIT_MCP_SERVER_URL!; // mcp_server_url from your Virtual MCP server const mcpToken = process.env.SCALEKIT_MCP_SESSION_TOKEN!; // minted on your backend for this user const mcpClient = await createMCPClient({ transport: { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${mcpToken}` }, }, }); const tools = await mcpClient.tools(); const { text } = await generateText({ model: openai('gpt-4o'), tools, stopWhen: stepCountIs(5), prompt: 'Fetch my last 5 unread emails and summarize them', }); await mcpClient.close(); console.log(text); ``` See [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/) to create the server, and [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/) to check the user's connections and mint a token. ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) - `GET` [List a user's scoped tools](https://docs.scalekit.com/agentkit/reference/tools/list-scoped-tools.md) --- Source: https://docs.scalekit.com/agentkit/examples/mastra.md # Mastra Connect a Mastra agent to Scalekit tools over MCP, all in Node.js. Create a Virtual MCP server, mint a session token for the user, and run the agent. Connect a Mastra agent to Scalekit tools using MCP. Mastra has native MCP support via `@mastra/mcp`. Pass a Scalekit Virtual MCP server URL and a session token, and Mastra handles tool discovery automatically. > note: Why MCP for Mastra > > Mastra's tool system uses Zod schemas internally. The MCP path skips manual schema conversion. Mastra discovers tools and their schemas directly from the Scalekit MCP server. The whole example runs in Node.js on your server, in two files. `setup.mts` creates the Virtual MCP server once. `agent.mts` connects the user, mints a session token and runs the agent, every run. Run each with `npx tsx`, for example `npx tsx agent.mts`. ## Before you start - Create a Gmail connection in **AgentKit** > **Connections**. The samples use the connection name `gmail`, so change it if yours differs. See [Set up a connection](https://docs.scalekit.com/agentkit/connections/). - Have the `.env` file from the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/), and add your OpenAI API key as `OPENAI_API_KEY` to it. - The samples load `.env` with `import 'dotenv/config'`. ## Install ```sh npm install @scalekit-sdk/node @mastra/core@1 @mastra/mcp@2 @ai-sdk/openai@4 dotenv ``` ## Initialize the Scalekit client Start both files with the client: ```typescript import 'dotenv/config'; import { ScalekitClient } from '@scalekit-sdk/node'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ); ``` ## Create the Virtual MCP server Create the server once per agent role, not once per user. Every user and every session reuses its `mcpServerUrl`. Server names are unique in an environment, so run this file once: a second run fails with `config with name 'gmail-user-tools' already exists`. ```typescript title="setup.mts" // Run once per agent role, not once per user const { config } = await scalekit.actions.mcp.createConfig({ name: 'gmail-user-tools', connectionToolMappings: [{ connectionName: 'gmail', tools: ['gmail_fetch_mails'] }], }); console.log('Virtual MCP server:', config!.id, config!.mcpServerUrl); ``` [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/) covers choosing tools, and listing, updating and deleting servers. ## Connect the user to Gmail The rest of the code goes in `agent.mts` and runs before each agent run. The Virtual MCP server only calls tools for users who have authorized the connection. Before each agent run, make sure the user's Gmail account is `ACTIVE`: ```typescript import { createInterface } from 'node:readline/promises'; import { ConnectorStatus } from '@scalekit-sdk/node'; // Connect the user's Gmail account, and wait until it's ACTIVE before calling tools const connectionName = 'gmail'; const identifier = 'user_123'; // your app's unique user ID let { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier, }); if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName, identifier }); console.log('Authorize Gmail:', link); const rl = createInterface({ input: process.stdin, output: process.stdout }); await rl.question('Press Enter after authorizing...'); rl.close(); // Fetch the account again to pick up the new status ({ connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier, })); } if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { throw new Error(`Gmail is not ACTIVE (status ${connectedAccount?.status}). Authorize it and run again.`); } ``` See [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) for production auth handling. ## Mint a session token for the user The server URL is the same for everyone. The **session token** carries the user's identity. Mint a new token before each agent run, after the connect step: ```typescript title="agent.mts" import { ScalekitNotFoundException, ScalekitUnauthorizedException } from '@scalekit-sdk/node'; // Run before each agent session, after the connect step let mcpServerUrl: string; let sessionToken: string; try { const { configs } = await scalekit.actions.mcp.listConfigs({ search: 'gmail-user-tools' }); const server = configs.find((c) => c.name === 'gmail-user-tools'); if (!server) throw new Error('Run setup.mts to create the gmail-user-tools server first'); mcpServerUrl = server.mcpServerUrl; const session = await scalekit.actions.mcp.createSessionToken({ mcpConfigId: server.id, identifier, // the user who authorized Gmail above expirySeconds: 60 * 60, }); sessionToken = session.token; } catch (err) { if (err instanceof ScalekitUnauthorizedException) { // Scalekit client credentials are wrong or expired: fix the environment variables } else if (err instanceof ScalekitNotFoundException) { // The server was deleted: create it again, then retry } throw err; // don't start the agent without a token } ``` Don't start the agent when minting fails: every tool call would return `401`. See [Errors and rate limits](https://docs.scalekit.com/agentkit/reference/errors/) for what an error response contains. Set `expirySeconds` above the expected run time; [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/) covers when to mint again. > caution: Do not share one session token across users > > The server URL is safe to share, because every user gets the same one. The session token is not. Each token is scoped to one identifier, and any request carrying it runs as that user. Mint a token per user on your server, and never send it to client-side code. ## Build the agent Pass the server URL to `MCPClient`, and the user's session token as a bearer header in `requestInit`. Mastra fetches the tool list and schemas automatically. It names each tool after the server key, so `gmail_fetch_mails` appears to the model as `scalekit_gmail_fetch_mails`. ```typescript title="agent.mts" import { Agent } from '@mastra/core/agent'; import { MCPClient } from '@mastra/mcp'; import { openai } from '@ai-sdk/openai'; const mcp = new MCPClient({ servers: { scalekit: { url: new URL(mcpServerUrl), requestInit: { headers: { Authorization: `Bearer ${sessionToken}` } }, }, }, }); try { const tools = await mcp.listTools(); const agent = new Agent({ id: 'gmail-assistant', name: 'Gmail assistant', instructions: 'You are a helpful Gmail assistant.', model: openai('gpt-4o'), tools, }); const result = await agent.generate('Fetch my last 5 unread emails and summarize them'); console.log(result.text); } finally { await mcp.disconnect(); } ``` ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/create-a-virtual-mcp-server.md) - `GET` [List Virtual MCP servers](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/list-virtual-mcp-servers.md) - `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md) --- Source: https://docs.scalekit.com/agentkit/examples/crewai.md # CrewAI Build a CrewAI agent with Scalekit-authenticated Gmail tools via MCP. CrewAI's MCPServerAdapter connects to a Scalekit MCP URL for automatic tool discovery. Build a CrewAI agent that reads a user's Gmail inbox. Scalekit handles OAuth, token storage, and exposes tools over MCP. CrewAI's `MCPServerAdapter` discovers the tools and their schemas from the server. Some schemas need a small patch, covered below. Full code on GitHub ## Before you start - Create a Gmail connection in **AgentKit** > **Connections**. The samples use the connection name `gmail`, so change it if yours differs. See [Set up a connection](https://docs.scalekit.com/agentkit/connections/). - Have the `.env` file from the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/), and add your OpenAI API key as `OPENAI_API_KEY` to it. - Create a Virtual MCP server named `gmail-user-tools` that includes the Gmail tools. See [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). - The samples load `.env` with `load_dotenv()` from `python-dotenv`. ## Install ```sh pip install crewai crewai-tools scalekit-sdk-python python-dotenv ``` ## Initialize ```python import os from scalekit import ScalekitClient from dotenv import find_dotenv, load_dotenv load_dotenv(find_dotenv()) scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit_client.actions ``` ## Connect the user to Gmail ```python # Connect the user's Gmail account, and wait until it's ACTIVE before calling tools connection_name = "gmail" identifier = "user_123" # your app's unique user ID response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=connection_name, identifier=identifier ) print("Authorize Gmail:", link.link) input("Press Enter after authorizing...") # Fetch the account again to pick up the new status response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier ) if response.connected_account.status != "ACTIVE": raise RuntimeError( f"Gmail is {response.connected_account.status}, not ACTIVE. Authorize it and run again." ) ``` See [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) for production auth handling. ## Build and run the agent Get the Virtual MCP server URL and mint a session token, then pass both to `MCPServerAdapter`. CrewAI discovers all available Gmail tools from the MCP server: ```python from crewai import Agent, Crew, LLM, Task from crewai_tools import MCPServerAdapter from datetime import timedelta # Retrieve config_id by listing Virtual MCP servers filtered by name list_response = actions.mcp.list_configs(filter_name="gmail-user-tools") mcp_server_url = list_response.configs[0].mcp_server_url mcp_id = list_response.configs[0].id token_response = actions.mcp.create_session_token( mcp_config_id=mcp_id, identifier="user_123", expiry=timedelta(hours=1), ) with MCPServerAdapter({ "url": mcp_server_url, "headers": {"Authorization": f"Bearer {token_response.token}"}, "transport": "streamable-http", }) as tools: agent = Agent( role="Email Assistant", goal="Fetch and summarize the user's unread emails", backstory="You are a helpful assistant with access to the user's Gmail inbox.", tools=tools, llm=LLM( model=os.getenv("LLM_MODEL", "gpt-4o"), base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.getenv("OPENAI_API_KEY"), ), verbose=True, ) task = Task( description="Fetch the last 5 unread emails and provide a brief summary of each.", expected_output="A list of 5 unread emails with subject, sender, and a one-sentence summary.", agent=agent, ) result = Crew(agents=[agent], tasks=[task]).kickoff() print(result) ``` > note: Nullable schema fields > > Some Scalekit tool schemas include nullable types (`{"type": ["string", "null"]}`) that CrewAI's schema converter doesn't handle out of the box. If you see a `TypeError` during tool parsing, apply the [schema patch](https://github.com/scalekit-developers/crewai-scalekit-example/blob/main/agent.py#L28-L43) at the top of your script. ## Multi-agent crew CrewAI's real strength is multi-agent orchestration. For a full example that splits email triage across three specialized agents (scanner, prioritizer, drafter), see the [CrewAI email triage cookbook](https://docs.scalekit.com/cookbooks/crewai-agentkit-email-triage/). ## Get the MCP server URL The code above reads `mcp_server_url` from a Virtual MCP server. Create one in the Scalekit dashboard under **AgentKit** > **Virtual MCP Servers**, or with the API. See [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `GET` [List Virtual MCP servers](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/list-virtual-mcp-servers.md) - `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md) --- Source: https://docs.scalekit.com/agentkit/examples/claude-managed-agents.md # Claude Managed Agents Give Claude Managed Agents per-user tools with AgentKit: create a Virtual MCP server, keep each user's session token in their own Anthropic vault, and run it. Run a background agent that reads Gmail and creates Google Calendar events — without managing any agent loop. Anthropic handles tool discovery, execution, retries, and session state. You provide the task. > tip: Complete working demo on GitHub > > Browse the full source for this guide at [GitHub](https://github.com/scalekit-inc/python-connect-demos/tree/main/claude-managed-agents). Scalekit connects the agent to user-authorized tools via a [Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/overview/). Before each run, you mint a short-lived session token and store it in that user's own Anthropic vault. The agent accesses the MCP server using the vault credential. ## Before you start - Create Gmail and Google Calendar connections in **AgentKit** > **Connections**. The samples use the connection names `gmail` and `googlecalendar`, so change them if yours differ. See [Set up a connection](https://docs.scalekit.com/agentkit/connections/). - Have the `.env` file from the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/), and add to it your [Anthropic API key](https://platform.anthropic.com/settings/keys) with access to the Managed Agents beta as `ANTHROPIC_API_KEY`, and your Anthropic environment ID as `ANTHROPIC_ENVIRONMENT_ID`. - The samples load `.env` with `load_dotenv()` from `python-dotenv`. ## How it works The flow has three phases: 1. **Build** (one-time) — Create a Virtual MCP server and a Claude Managed Agent. Save `mcp_id` and `agent_id`. 2. **Authorize user for external connections** (once per user) — Authorize the user's Gmail and Google Calendar accounts. 3. **Run a session** (per agent run) — Check connections, mint a session token, store it in that user's own Anthropic vault, and start a session. Never share one vault across users: a session acts as whichever user's token is in its vault. ## Install ```sh pip install anthropic scalekit-sdk-python python-dotenv ``` ## Build Run this once to create your Virtual MCP server and Claude Managed Agent. Save the returned `mcp_id` and `agent_id` — you reuse them for every user and every session. ```python title="builder.py" import os import anthropic from scalekit import ScalekitClient from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping from dotenv import load_dotenv load_dotenv() anthropic_client = anthropic.Anthropic() scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) GMAIL_TOOLS = ["gmail_fetch_mails"] GCAL_TOOLS = [ "googlecalendar_list_calendars", "googlecalendar_list_events", "googlecalendar_get_event_by_id", "googlecalendar_create_event", "googlecalendar_update_event", ] vmcp_response = scalekit_client.actions.mcp.create_config( name="email-calendar-demo", connection_tool_mappings=[ McpConfigConnectionToolMapping( connection_name="gmail", tools=GMAIL_TOOLS, ), McpConfigConnectionToolMapping( connection_name="googlecalendar", tools=GCAL_TOOLS, ), ], ) mcp_id = vmcp_response.config.id mcp_server_url = vmcp_response.config.mcp_server_url agent = anthropic_client.beta.agents.create( name="Email Meeting Manager", model="claude-haiku-4-5", system=( "You are an email and calendar assistant. When invoked, you will:\n" "1. Fetch the single most recent unread email from Gmail.\n" "2. Summarize it in 2-3 sentences.\n" "3. Create a Google Calendar event titled 'Action Required: ' " "with your summary as the description." ), mcp_servers=[ { "type": "url", "name": "email-calendar-mcp", "url": mcp_server_url, } ], tools=[ {"type": "agent_toolset_20260401", "default_config": {"enabled": True}}, { "type": "mcp_toolset", "mcp_server_name": "email-calendar-mcp", "default_config": { "enabled": True, "permission_policy": {"type": "always_allow"}, }, }, ], ) print("Add these to your .env file:") print(f"SCALEKIT_MCP_CONFIG_ID={mcp_id}") print(f"ANTHROPIC_AGENT_ID={agent.id}") ``` Run `python builder.py` once and add the two printed lines to your `.env` file. The next two scripts read them from there. The agent definition references the `mcp_server_url` but carries no auth credentials. Authentication is injected at runtime via the Anthropic vault. ## Authorize user for external connections Each user authorizes their Gmail and Google Calendar accounts once. All future agent sessions for that user reuse those connections. ```python title="executor_setup.py" import os from scalekit import ScalekitClient from dotenv import load_dotenv load_dotenv() scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) mcp_id = os.environ["SCALEKIT_MCP_CONFIG_ID"] # printed by builder.py identifier = "user_123" # your app's unique user ID accounts_response = scalekit_client.actions.mcp.list_mcp_connected_accounts( config_id=mcp_id, identifier=identifier, ) for account in accounts_response.connected_accounts: status = (account.connected_account_status or "").upper() if status != "ACTIVE": auth_response = scalekit_client.actions.get_authorization_link( identifier=identifier, connection_name=account.connection_name, ) print(f"{account.connection_name} needs auth: {auth_response.link}") else: print(f"✓ {account.connection_name} is ACTIVE") ``` Surface the auth link in your app UI or send it via email. Users only need to do this once. ## Run a session Run `executor.py` for each agent run. It stops before starting a session if any connection isn't `ACTIVE`, so the agent never runs with a tool it can't call. 1. ## Set up the clients and the run ```python title="executor.py" import json import os import sys from datetime import timedelta from pathlib import Path import anthropic from dotenv import load_dotenv from scalekit import ScalekitClient load_dotenv() anthropic_client = anthropic.Anthropic() scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) mcp_id = os.environ["SCALEKIT_MCP_CONFIG_ID"] # printed by builder.py agent_id = os.environ["ANTHROPIC_AGENT_ID"] # printed by builder.py identifier = "user_123" # your app's unique user ID prompt = "Process my most recent unread email." # Each user gets their own vault, created on their first run. # This example keeps the IDs in a local file. In production, store them # in your database, keyed by the user. VAULTS_FILE = Path("vaults.json") vaults = json.loads(VAULTS_FILE.read_text()) if VAULTS_FILE.exists() else {} user_vault = vaults.get(identifier) ``` 2. ## Check that connections are active OAuth tokens for connected accounts can expire or be revoked, so check every connection before minting a token. If any isn't `ACTIVE`, stop and send the user back through `executor_setup.py`. ```python title="executor.py" accounts_response = scalekit_client.actions.mcp.list_mcp_connected_accounts( config_id=mcp_id, identifier=identifier, ) inactive = [ a.connection_name for a in accounts_response.connected_accounts if (a.connected_account_status or "").upper() != "ACTIVE" ] if inactive: sys.exit(f"Not ACTIVE: {', '.join(inactive)}. Run executor_setup.py to reauthorize.") ``` 3. ## Mint a session token and store it in the vault Mint a short-lived session token and store it in this user's Anthropic vault. Claude Managed Agents access the MCP server using the vault credential, not a direct bearer header, and the session acts as whichever user's token is in the vault. So give each user their own vault, and never put two users' tokens in the same one. ```python title="executor.py" configs_response = scalekit_client.actions.mcp.list_configs(filter_id=mcp_id) mcp_server_url = configs_response.configs[0].mcp_server_url token_response = scalekit_client.actions.mcp.create_session_token( mcp_config_id=mcp_id, identifier=identifier, expiry=timedelta(hours=1), ) token = token_response.token # Update this user's own credential, or create their vault on their first run. # Never reuse one vault across users: a session acts as whoever's token is in it. if user_vault: vault_id = user_vault["vault_id"] anthropic_client.beta.vaults.credentials.update( user_vault["credential_id"], vault_id=vault_id, auth={"type": "static_bearer", "token": token}, ) else: vault = anthropic_client.beta.vaults.create(display_name=f"email-calendar-{identifier}") vault_id = vault.id credential = anthropic_client.beta.vaults.credentials.create( vault_id, display_name="email-calendar-credential", auth={ "type": "static_bearer", "mcp_server_url": mcp_server_url, "token": token, }, ) vaults[identifier] = {"vault_id": vault_id, "credential_id": credential.id} VAULTS_FILE.write_text(json.dumps(vaults, indent=2)) ``` 4. ## Start the session Pass `vault_ids` to the session so the agent can authenticate against the MCP server. ```python title="executor.py" session = anthropic_client.beta.sessions.create( agent=agent_id, environment_id=os.environ["ANTHROPIC_ENVIRONMENT_ID"], vault_ids=[vault_id], ) with anthropic_client.beta.sessions.events.stream(session_id=session.id) as stream: anthropic_client.beta.sessions.events.send( session_id=session.id, events=[{"type": "user.message", "content": [{"type": "text", "text": prompt}]}], ) for event in stream: if event.type == "agent.message": for block in event.content: if block.type == "text": print(block.text, end="", flush=True) elif event.type == "agent.mcp_tool_use": print(f"\n→ {event.name}", flush=True) elif event.type == "session.status_terminated": break elif event.type == "session.status_idle": # The session also goes idle while it waits on you (requires_action). # Stop only when it's idle for another reason, such as end_turn. if event.stop_reason.type != "requires_action": break ``` ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) - `POST` [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/create-a-virtual-mcp-server.md) - `GET` [List Virtual MCP servers](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/list-virtual-mcp-servers.md) - `POST` [Check a user's connected accounts for a server](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/check-connected-accounts-for-a-server.md) - `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md) --- Source: https://docs.scalekit.com/cookbooks/set-up-agentkit-with-your-coding-agent.md # Set up AgentKit with your coding agent Add Scalekit AgentKit to your codebase using Claude Code, Codex, GitHub Copilot CLI, Cursor, or any of 40+ coding agents. Install the authstack plugin into your coding agent and paste one prompt. The agent generates client initialization, connected account management, OAuth authorization, and token handling — no boilerplate required. ## Before you start - A Scalekit account at [app.scalekit.com](https://app.scalekit.com) - A connector configured under **AgentKit** > **Connections**. The prompt defaults to GitHub (`github-connect`). Name another connector if you want that instead. - Your API credentials from **Developers** > **Settings** > **API Credentials** ## Pick your coding agent **Recommended: one command** ```bash title="Terminal" npx @scalekit-inc/cli setup ``` For repeated use: `npm install -g @scalekit-inc/cli` then `scalekit setup`. The CLI installs the authstack plugin for your editor. Then load the shipped skill `integrate-agentkit`. Complete any browser OAuth prompt for the Scalekit MCP server. Done: the command exits 0 and `integrate-agentkit` is available. Then copy the AgentKit prompt below (or describe your goal naturally). **Per-tool details** After the CLI (or if you prefer tool-native flows): - Claude Code / Copilot: marketplace + plugin install is handled by the CLI. - Cursor / Codex: plugins are installed locally by the CLI. - Other agents: use the skills option in the CLI or `npx skills add scalekit-inc/authstack --skill integrate-agentkit`. Then copy the AgentKit prompt below. ## Verify the setup 1. **Set environment variables** — copy `SCALEKIT_CLIENT_ID`, `SCALEKIT_CLIENT_SECRET`, and `SCALEKIT_ENVIRONMENT_URL` from **Developers** > **Settings** > **API Credentials** in the dashboard. Done: all three names are set in `.env`. 2. **Trigger the authorization flow** — run the generated example and confirm the browser redirects to the connector's consent page. Done: the consent page opens. 3. **Call a tool** — after consent, run a read-only tool through Scalekit, such as `github_user_get_authenticated` for GitHub. Done: the tool call returns data from the connector. > caution: Review generated code before deploying > > Verify that token validation logic, error handling, and environment variable references match your application's requirements. The generated code is a foundation, not a finished implementation. ## Troubleshooting **The agent generated code for a connector I haven't configured yet** The plugin uses the connector name you provide in the prompt. If that connector isn't configured in your Scalekit Dashboard, creating the authorization link fails with `connection not found for the given key`. Fix: in the [Scalekit Dashboard](https://app.scalekit.com), go to **AgentKit** > **Connections** > **Create Connection**, finish the connection, then re-run the agent prompt with the exact connection name from the dashboard. **I want to swap connectors after the initial generation** Re-run the implementation prompt with the new connector name. The agent updates the connector reference in the client initialization and regenerates the tool calls. Existing connected accounts for the old connector are not affected. **The scaffolded code references an SDK version that doesn't match my lockfile** The plugin targets the latest stable Scalekit SDK. If your lockfile pins an older version, either upgrade the SDK (`npm install @scalekit-sdk/node@latest` or equivalent) or ask the agent to regenerate using your pinned version by adding "use SDK version X.Y.Z" to the prompt. --- Source: https://docs.scalekit.com/agentkit/hermes.md # Connect Hermes to AgentKit Connect a Hermes agent to GitHub, Gmail and Slack through Scalekit AgentKit: install the skill, set your credentials and make your first tool call as a user. [Hermes](https://hermes-agent.nousresearch.com/docs/) is the self-improving AI agent from Nous Research. Hermes runs where you put it: a laptop, a small VPS, or a GPU cluster. You reach Hermes from the command line, or from a messaging app such as Slack or Telegram, and Hermes also runs work on a cron schedule. Hermes ships with 60+ built-in tools, and the built-in tools stop at the edge of your machine. A Hermes agent that reads your Gmail, posts to your Slack, or opens a GitHub pull request needs an access token for each app. Every access token belongs to one user. Scalekit holds the connected account for that user, stores the access token, and refreshes the access token. Hermes reaches Scalekit through a **skill**. A skill is an on-demand instruction set plus scripts, and Hermes loads a skill from `~/.hermes/skills/` when a chat calls for one. The skill tells Hermes how to find the Scalekit connection, how to check that the user finished auth, and how to call the tool with the stored token. The steps below install the skill. ```d2 title="Hermes agent calling third-party services such as Gmail through Scalekit AgentKit's OAuth handler and token vault" direction: right Hermes: "Hermes Agent" { style.font-size: 18 } Scalekit: { label: "Scalekit AgentKit" Auth: "OAuth handler" Vault: "Token vault" } Providers: { label: "Third-party services" Gmail: "Gmail" Slack: "Slack" Salesforce: "Salesforce" More: "200+ more" } Hermes -> Scalekit.Auth: "Execute tools" Scalekit.Vault -> Providers.Gmail Scalekit.Vault -> Providers.Slack Scalekit.Vault -> Providers.Salesforce Scalekit.Vault -> Providers.More ``` ## Prerequisites - [Hermes Agent](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) installed - A Scalekit account with AgentKit enabled: [sign up at app.scalekit.com](https://app.scalekit.com) - At least one connection in **Dashboard > AgentKit > Connections**. Create the connection before you ask Hermes to use the app. See [connections](https://docs.scalekit.com/agentkit/connectors/). - [`uv`](https://docs.astral.sh/uv/) on your `PATH` ## How the skill works The skill runs the following loop when you name an app: 1. The skill looks up the Scalekit connection you already created for [GitHub](https://docs.scalekit.com/agentkit/connectors/github/), [Gmail](https://docs.scalekit.com/agentkit/connectors/gmail/), or another app. 2. The skill checks the user's connected account. `ACTIVE` means auth is complete and Scalekit holds a token. 3. The skill returns an authorization link when the connected account is not `ACTIVE`. 4. The skill fetches the tool schema, calls the tool, and returns the result. 5. The skill calls the Scalekit HTTP proxy when the connector has no named tool. An authorization link is a one-time URL. The hosted page shows OAuth consent, or an API-key form. ## Get started 1. ### Install the skill Install `hermes-delegated-auth` from authstack: ```bash hermes skills install scalekit-inc/authstack/kits/agentkit/host/hermes-delegated-auth ``` Confirm the skill is enabled: ```bash hermes skills list ``` ```txt │ hermes-delegated-auth │ │ local │ local │ enabled │ ``` Install the Python dependencies: ```bash cd "${HERMES_HOME:-$HOME/.hermes}/skills/hermes-delegated-auth" uv sync ``` New chats load `/hermes-delegated-auth`. Run `/reset` in a chat that is already open. `HERMES_HOME` changes the skill path. The commands on this page use `~/.hermes`. > caution: Install from the authstack path > > A raw `SKILL.md` URL leaves out `scripts/`, and the skill then fails at run time. The `integrate-agentkit-host` skill is for coding agents, not for Hermes. 2. ### Configure credentials Put only Scalekit client credentials in `~/.hermes/.env`. Scalekit stores and refreshes the provider tokens for GitHub, Gmail, and Slack. Provider tokens never belong in a Hermes file. ```bash title="~/.hermes/.env" SCALEKIT_CLIENT_ID=skc_your_client_id # Keep this secret: anyone who has it can call tools as any ACTIVE identifier. SCALEKIT_CLIENT_SECRET=your_client_secret SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev SCALEKIT_IDENTIFIER=usr_8f3a2c ``` | Parameter | Description | |-----------|-------------| | `SCALEKIT_CLIENT_ID` | Your Scalekit client ID | | `SCALEKIT_CLIENT_SECRET` | Your Scalekit client secret | | `SCALEKIT_ENVIRONMENT_URL` | Your Scalekit environment URL | | `SCALEKIT_IDENTIFIER` | Default user the host acts as | Copy the client ID, client secret, and environment URL from **Dashboard > Developers > Settings > API Credentials**. `SCALEKIT_IDENTIFIER` is not a dashboard credential. Pick an opaque id that only your system knows, for example `usr_8f3a2c`. Every `tool_exec.py` command needs `SCALEKIT_IDENTIFIER`, including `--list-connections`. > caution: Keep the identifier unguessable > > An email address or a first name is guessable. `SCALEKIT_CLIENT_SECRET` lets anyone call tools as any `ACTIVE` identifier, so a guessable identifier widens the damage. Add [user verification](https://docs.scalekit.com/agentkit/user-verification/) in a multi-user product so the wrong person cannot activate the slot. 3. ### Ask Hermes to act Start a chat and ask for a real action. Hermes acts as `SCALEKIT_IDENTIFIER` by default, and Hermes passes `--identifier` to the skill when you name another user in the prompt. **GitHub** ```txt You: Who am I on GitHub? ``` Hermes loads the skill and runs these steps: 1. Hermes looks up the GitHub connection. 2. Hermes checks that the connected account is `ACTIVE`, and returns an authorization link when the account is not. 3. Hermes fetches the tool schema. 4. Hermes calls the tool and returns your GitHub login. **Gmail** ```txt You: Show me my latest unread emails ``` Hermes runs these steps: 1. Hermes looks up the Gmail connection. 2. Hermes returns an authorization link when you have not authorized Gmail yet. 3. Hermes fetches the tool schema. 4. Hermes returns the mail. **Notion** ```txt You: Read my Notion page https://notion.so/My-Page-abc123 ``` Hermes runs these steps: 1. Hermes looks up the Notion connection. 2. Hermes returns an authorization link when you have not authorized Notion yet. 3. Hermes fetches the page tool schema. 4. Hermes returns the page content. **Slack (as a user)** ```txt You: As identifier usr_8f3a2c, list my unread Slack DMs ``` Hermes runs these steps: 1. Hermes looks up the Slack connection. 2. Hermes returns an authorization link when the Slack connected account is not `ACTIVE`. 3. Hermes fetches the Slack tool schema. 4. Hermes returns the direct messages (DMs) as `usr_8f3a2c`. Create the Slack connection with **User scope** for the prompt above. A Slack connection with **Bot scope** acts as your Slack app instead of acting as the person. See the [Slack connector](https://docs.scalekit.com/agentkit/connectors/slack/). **Google Calendar** ```txt You: Create an out-of-office event tomorrow on the calendar for usr_8f3a2c ``` Hermes runs these steps: 1. Hermes looks up the Google Calendar connection for `usr_8f3a2c`. 2. Hermes returns an authorization link when the connected account is not `ACTIVE`. 3. Hermes calls the create-event tool. 4. Scalekit writes the event with the `usr_8f3a2c` token. Name the skill in the prompt to force the same path: ```txt /hermes-delegated-auth who am I on GitHub? ``` ## Verify it works Confirm all of the following: - The skill is listed (`hermes skills list` or `/skills`) as `hermes-delegated-auth` - The connected account is `ACTIVE` after you finish the authorization link - Hermes returns data from the provider (the GitHub login, or unread mail) Open the authorization link again and retry the same prompt if the account stays inactive. ## Use Slack as a bot or as a user Slack issues two kinds of token, and the token decides whose name appears on a message. A bot token posts as your Slack app. A user token posts as the person who authorized the app. Authorizing the Hermes gateway grants a bot token, so the gateway alone cannot post as a person. | Job | Use | |-----|-----| | Chat with the agent in a channel or DM | Hermes Slack **gateway**. Bot tokens (`xoxb-` + `xapp-`) in `~/.hermes/.env`. See the [Hermes Slack setup](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack). | | Read private history, post, or act as a user | Scalekit Slack connection created with **User scope**. See the [Slack connector](https://docs.scalekit.com/agentkit/connectors/slack/). | The gateway bot is a channel into the agent. The gateway bot is not `usr_8f3a2c`. Always-on hosts use both identities. The gateway hears the channel. Scalekit acts as the user. ## Run jobs on a schedule Attach the skill to a Hermes cron job. Each fire is a fresh session. The skill uses Scalekit client credentials. You do not mint a session token. ```bash hermes cron create "0 9 * * *" "List unread emails for usr_8f3a2c and post a 5-line summary" --skill hermes-delegated-auth ``` Or in chat: ```txt /cron add "every weekday at 9am" "List unread emails for usr_8f3a2c and post a 5-line summary" --skill hermes-delegated-auth ``` The connected account must already be `ACTIVE`. Cron cannot click an authorization link. Leave the gateway running on a headless host. A new authorization link can then land in Slack or Telegram. Do not put Gmail or Calendar refresh tokens in `~/.hermes/.env`. ## Choose how Hermes reaches Scalekit Two patterns connect Hermes to Scalekit. Pick the one that matches your host. **Pattern A: delegated skill.** The default for Hermes. The host holds Scalekit client credentials, and each turn names an identifier. No token sits on the host, so nothing expires. **Pattern B: Virtual MCP in your app.** Your application mints a token before each agent run and passes it to the agent framework. Claude Managed Agents, Mastra, and CrewAI use Pattern B. | Question | A: delegated skill | B: Virtual MCP in your app | |---|---|---| | Who runs the agent | Hermes | your app | | How many end users | many; switch with `--identifier` | many | | Credential on the host | Scalekit client ID and secret | none | | Anything expires? | no | yes, per run | | Who mints a replacement | nobody; there is no token | your code, before each run | | Tool scoping | the full connector catalog | only the tools in the server | | Best for | multi-user hosts and cron | short agent runs | The rest of this page covers Pattern A. For Pattern B, see [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). ## Avoid these mistakes - Do not put provider tokens in `~/.hermes/.env`. Put only Scalekit credentials there. - Do not set `SCALEKIT_IDENTIFIER` to an email or another guessable value. - Do not use the bundled Google Workspace skill (`~/.hermes/google_token.json`) if AgentKit owns the Google user. `~/.hermes/google_token.json` is one laptop login, not a per-user identifier. - Do not run `hermes mcp login` against Scalekit Virtual MCP (a Scalekit MCP URL plus a session token). Virtual MCP uses a static bearer session token, and a static bearer token is not MCP OAuth. - Do not treat the Slack bot token as “send as usr_8f3a2c”. - Do not rely on Hermes to sign in your end users. One gateway belongs to one operator, who holds the Scalekit credentials. That operator's host acts for many end users, each with their own identifier, passed with `--identifier`. Confirm who each user is in your own app with [user verification](https://docs.scalekit.com/agentkit/user-verification/), and mint per-end-user Virtual MCP session tokens in your application, not inside Hermes. ## Common scenarios **How do I authorize a new connection?** The skill returns an authorization link if the account is not `ACTIVE`. Open the link, finish the flow on the hosted page, then return to Hermes and retry. The hosted page adapts to the connection. An OAuth connector asks for your consent. An API key connector, such as Snowflake, asks for the credential. Hermes never collects the credential in chat. **How do I switch users?** Set `SCALEKIT_IDENTIFIER` in `~/.hermes/.env` as the default. Name another user in the prompt to override the default for one turn, for example `as identifier usr_8f3a2c`. Hermes then passes `--identifier usr_8f3a2c` to the skill, and Scalekit scopes the tools to that user's connected account. **Why do I see "connection not found"?** 1. Confirm the connection exists in **Dashboard > AgentKit > Connections** 2. Confirm the connection is complete, not a draft 3. Confirm `SCALEKIT_ENVIRONMENT_URL` matches the environment that holds the connection Use the connection `key_id` as the connection name, for example `github-connect`. Do not use the `conn_…` id. **The connected account is not ACTIVE** Check the state in **Dashboard > AgentKit > Connected accounts**: | State | What you do | |-------|-------------| | `PENDING_AUTH` | Open the authorization link and finish OAuth | | `PENDING_VERIFICATION` | Complete [user verification](https://docs.scalekit.com/agentkit/user-verification/) | | `EXPIRED` | Open a new authorization link. The token expired, or the user revoked access at the provider | | `DISCONNECTED` | The account was disconnected in Scalekit. Open a new authorization link, then retry | See [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/). **Can I use Virtual MCP with Hermes?** Use the skill on the Hermes host. `hermes-delegated-auth` calls `execute_tool` with Scalekit client credentials and an identifier. No token sits on the host, so nothing expires, and one host can serve many users and run cron jobs. When you want each run limited to a chosen set of tools, mint Virtual MCP session tokens in your own application and pass them to the agent, as in [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). Mint a token per end user in your application, not inside Hermes. Do not set `auth: oauth` on a Scalekit Virtual MCP server. The server takes a bearer token, not MCP OAuth. **Why does `hermes mcp login` fail against Scalekit?** `hermes mcp login` performs MCP OAuth against a vendor's MCP server. A Scalekit Virtual MCP server does not use MCP OAuth. It takes a static URL plus a session token that **your application** mints with `create_session_token`. Set the bearer header in `config.yaml` instead. See [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). ## Next - [Browse connectors](https://docs.scalekit.com/agentkit/connectors/): Every app AgentKit connects to, with its tools - [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/): Create a connected account and send the user an authorization link - [Example repo](https://github.com/scalekit-developers/hermes-agentkit-example): Runnable companion on scalekit-developers --- Source: https://docs.scalekit.com/agentkit/bring-your-own-connector/overview.md # When to add a connector Add your own connector when an app isn't in the AgentKit catalog. Scalekit still handles user authorization, stored credentials and the calls your agent makes. Add your own connector when the API or MCP server you need is not available in Scalekit's built-in catalog — custom connectors support any SaaS API, partner system, internal API, or remote MCP server while keeping authentication, authorization, and secure API access in Scalekit. Once the connector is created, you use the same flow as other connectors: create a connection, create or fetch a connected account, authorize the user, and perform tool calling. Custom connectors appear alongside built-in connectors when you create a connection in Scalekit: > Image: Custom connector shown alongside built-in connectors in the connector selection view ## Why add your own connector Adding your own connector lets you: - Extend beyond the built-in connector catalog without inventing a separate auth stack - Bring unsupported SaaS APIs, partner systems, internal APIs, and remote MCP servers into the same secure access model - Reuse connections, connected accounts, and user authorization instead of building one-off auth plumbing - Keep credential handling, authorization, and governed API access centralized in Scalekit - Move from connector definition to live upstream calls through the API proxy (REST) or tool calling (MCP) using the same runtime model as other integrations ## How adding your own connector works Adding your own connector uses the same model as built-in connectors: 1. Create a connector definition 2. Create a connection in Scalekit Dashboard 3. Create a connected account and authorize the user 4. Call tools — via the API proxy (`actions.request()`) for REST API connectors, or via MCP tool calling for MCP connectors Creating the connector definition tells Scalekit how to authenticate to the upstream API or MCP server. After that, connections, connected accounts, user authorization, and the call runtime work the same way as they do for built-in connectors. ## Next - [Create a connector](https://docs.scalekit.com/agentkit/bring-your-own-connector/create-connector/): Define how Scalekit authenticates to the app. - [Call your connector](https://docs.scalekit.com/agentkit/bring-your-own-connector/making-tool-calls/): Make API or MCP tool calls through your connector. --- Source: https://docs.scalekit.com/agentkit/bring-your-own-connector/create-connector.md # Create a connector Create a custom AgentKit connector for an app that isn't in the catalog: pick OAuth, API key, bearer or basic auth, build the definition and manage it by API. Create a custom connector to bring an unsupported API or MCP server into Scalekit's secure access model. This guide walks you through building the connector payload, creating the connector, and managing it over its lifecycle - list, update, and delete - with the management API. Check out the examples **Prerequisites** You need three credentials from your Scalekit environment: - `SCALEKIT_ENVIRONMENT_URL` - the base URL of your Scalekit environment - `SCALEKIT_CLIENT_ID` - your environment's client ID - `SCALEKIT_CLIENT_SECRET` - your environment's client secret Find these in the Scalekit Dashboard under **Developers → Settings → API Credentials**. ## Create a connector Create a connector in the Scalekit Dashboard or with the management API. The dashboard provides a guided form for MCP connectors; the management API gives you scriptable control over every connector type and auth pattern. ### Create an MCP connector in the dashboard Add an MCP connector through a guided form - no payload required. 1. In the Scalekit Dashboard, switch to **AgentKit**. 2. Select **Connectors**. 3. Select **Create custom connector**. 4. Complete the **Add MCP connector** form: - **Display name**: a name for the connector, such as `Example MCP`. - **Description**: a short description of what the connector connects to. - **Icon URL** (optional): an icon for the connector. Must start with `https://`. For best results, use an 800×800px SVG image, such as `https://cdn.example.com/icon.svg`. - **Server URL**: the base URL of the MCP server. Must start with `https://`, such as `https://app.example.com/mcp`. - **Metadata** (optional): key-value pairs for the connector. Values must be plain strings; nested objects are not supported. For **Auth type**, choose how users authenticate when they connect their account: - **OAuth**: users authorize access through the provider's OAuth flow, and Scalekit handles the token exchange. - **Bearer token**: users provide a long-lived token issued by the provider. - **API key**: users provide an API key issued by the provider. - **No authentication**: the server is public and requires no credentials. Use this only when the server intentionally allows unauthenticated access and exposes no user-specific data or privileged operations, since every user shares the same anonymous access. 5. Select **Save**. The connector is ready to use when you create a connection. ### Create a connector with the management API Build the connector payload using the reference and examples that follow, then create the connector with the management API. **Understand the connector payload** Supported auth types are `OAUTH`, `BASIC`, `BEARER`, and `API_KEY`. Use `OAUTH` when the upstream API or MCP server requires a user authorization flow and token exchange. Use `BASIC`, `BEARER`, or `API_KEY` when it accepts static credentials or long-lived tokens. MCP providers use the same four auth types as REST API providers, with `is_mcp: true` set in each `auth_patterns[]` entry. OAuth MCP connectors use a simplified `oauth_config: {"pkce_enabled": true}` - the MCP server handles authorization via Dynamic Client Registration. Non-OAuth MCP connectors omit `oauth_config` entirely. MCP connectors can also use `NO_AUTH` for public servers that require no credentials - set `is_mcp: true`, use an empty `fields: []`, and omit `oauth_config`. Use `NO_AUTH` only when the upstream intentionally allows unauthenticated public access and exposes no user-specific data or privileged operations; every user of the connector shares the same anonymous access. The connector payload uses these common top-level fields: - `display_name`: Human-readable name for the custom connector - `description`: Short description of what the connector connects to - `auth_patterns`: Authentication options supported by the connector - `proxy_url`: Base URL the proxy should call for the upstream API (mandatory) - `proxy_enabled`: Whether the proxy is enabled for the connector (mandatory, should be true) `proxy_url` can also include templated fields when the upstream API requires account-specific values, for example `https://{{domain}}/api`. Within `auth_patterns`, the most common fields are: - `type`: The auth type, such as OAUTH, BASIC, BEARER, or API_KEY - `display_name`: Label shown for that auth option - `description`: Short explanation of the auth method - `fields`: Inputs collected for static auth providers such as BASIC, BEARER, and API_KEY. These usually store values such as `username`, `password`, `token`, `api_key`, `domain`, or `version`. - `account_fields`: Inputs collected for OAUTH connectors when account-scoped values are needed. This is typically used for values tied to a connected account, such as named path parameters. - `oauth_config`: OAuth-specific configuration, such as authorize and token endpoints - `auth_header_key_override`: Custom header name when the upstream does not use `Authorization`. For example, some APIs expect auth in a header such as `X-API-Key` instead of the standard `Authorization` header. - `auth_field_mutations`: Value transformations applied before the credential is sent. This is useful when the upstream expects a prefix, suffix, or default companion value, such as adding a token prefix or setting a fallback password value for Basic auth. - `is_mcp`: Set to `true` when the upstream is an MCP server. Tells Scalekit to route the connector through MCP tool calling instead of the HTTP proxy. Below are example payloads for API and MCP connectors across all supported auth patterns. > caution: Stateless MCP servers only > > Scalekit connects to **stateless MCP servers** only. Stateful MCP servers that require persistent sticky connections or MCP session IDs are not supported. **API Connector** **OAuth** ```json { "display_name": "My Asana", "description": "Connect to Asana. Manage tasks, projects, teams, and workflow automation", "auth_patterns": [ { "type": "OAUTH", "display_name": "OAuth 2.0", "description": "Authenticate with Asana using OAuth 2.0 for comprehensive project management", "fields": [], "oauth_config": { "authorize_uri": "https://app.asana.com/-/oauth_authorize", "token_uri": "https://app.asana.com/-/oauth_token", "user_info_uri": "https://app.asana.com/api/1.0/users/me", "available_scopes": [ { "scope": "profile", "display_name": "Profile", "description": "Access user profile information", "required": true }, { "scope": "email", "display_name": "Email", "description": "Access user email address", "required": true } ] } } ], "proxy_url": "https://app.asana.com/api", "proxy_enabled": true } ``` **Bearer** ```json { "display_name": "My Bearer Token Provider", "description": "Connect to an API that accepts a static bearer token", "auth_patterns": [ { "type": "BEARER", "display_name": "Bearer Token", "description": "Authenticate with a static bearer token", "fields": [ { "field_name": "token", "label": "Bearer Token", "input_type": "password", "hint": "Your long-lived bearer token", "required": true } ] } ], "proxy_url": "https://api.example.com", "proxy_enabled": true } ``` **Basic** ```json { "display_name": "My Freshdesk", "description": "Connect to Freshdesk. Manage tickets, contacts, companies, and customer support workflows", "auth_patterns": [ { "type": "BASIC", "display_name": "Basic Auth", "description": "Authenticate with Freshdesk using Basic Auth with username and password for comprehensive helpdesk management", "fields": [ { "field_name": "domain", "label": "Freshdesk Domain", "input_type": "text", "hint": "Your Freshdesk domain (e.g., yourcompany.freshdesk.com)", "required": true }, { "field_name": "username", "label": "API Key", "input_type": "text", "hint": "Your Freshdesk API Key", "required": true } ] } ], "proxy_url": "https://{{domain}}/api", "proxy_enabled": true } ``` **API Key** ```json { "display_name": "My Attention", "description": "Connect to Attention for AI insights, conversations, teams, and workflows", "auth_patterns": [ { "type": "API_KEY", "display_name": "API Key", "description": "Authenticate with Attention using an API Key", "fields": [ { "field_name": "api_key", "label": "Integration Token", "input_type": "password", "hint": "Your Attention API Key", "required": true } ] } ], "proxy_url": "https://api.attention.tech", "proxy_enabled": true } ``` **MCP Connector** **OAuth** ```json { "display_name": "GitHub MCP", "description": "Connect to GitHub MCP", "auth_patterns": [ { "description": "Authenticate with GitHub MCP using browser OAuth.", "display_name": "OAuth 2.1/DCR", "fields": [], "is_mcp": true, "oauth_config": { "pkce_enabled": true }, "type": "OAUTH" } ], "proxy_url": "https://api.githubcopilot.com/mcp/", "proxy_enabled": true } ``` **Bearer** ```json { "display_name": "Apify MCP", "description": "Connect to Apify MCP to run web scraping, browser automation, and data extraction Actors directly from your AI workflows.", "auth_patterns": [ { "description": "Authenticate with Apify using your API Token.", "display_name": "Apify Token", "fields": [ { "field_name": "token", "hint": "Your Apify API Token", "input_type": "password", "label": "Apify Token", "required": true } ], "is_mcp": true, "type": "BEARER" } ], "proxy_url": "https://mcp.apify.com", "proxy_enabled": true } ``` **Basic** ```json { "display_name": "My Internal MCP", "description": "Connect to an internal MCP server that authenticates with a username and password", "auth_patterns": [ { "type": "BASIC", "display_name": "Basic Auth", "description": "Authenticate with a username and password", "is_mcp": true, "fields": [ { "field_name": "username", "label": "Username", "input_type": "text", "hint": "Your username", "required": true }, { "field_name": "password", "label": "Password", "input_type": "password", "hint": "Your password", "required": true } ] } ], "proxy_url": "https://mcp.internal.example.com", "proxy_enabled": true } ``` **API Key** ```json { "display_name": "My API Key MCP", "description": "Connect to an MCP server that authenticates with a static API key", "auth_patterns": [ { "type": "API_KEY", "display_name": "API Key", "description": "Authenticate with a static API key", "is_mcp": true, "fields": [ { "field_name": "api_key", "label": "API Key", "input_type": "password", "hint": "Your API key", "required": true } ] } ], "proxy_url": "https://mcp.example.com", "proxy_enabled": true } ``` **No Auth** ```json { "display_name": "Public Docs MCP", "description": "Connect to a public MCP server that requires no credentials", "auth_patterns": [ { "type": "NO_AUTH", "display_name": "No Auth", "description": "Public server - no credentials required.", "is_mcp": true, "fields": [] } ], "proxy_url": "https://mcp.example.com", "proxy_enabled": true } ``` **Before submitting, review the final payload carefully:** - `display_name` and `description` - The selected auth `type` - Required `fields` and `account_fields` - OAuth endpoints and scopes, if the connector uses OAuth - `proxy_url` - Whether `is_mcp` is set to `true` for MCP providers **Generate an access token** All API requests require a short-lived access token. Generate one using your `SCALEKIT_CLIENT_ID` and `SCALEKIT_CLIENT_SECRET`: ```bash curl --location "$SCALEKIT_ENVIRONMENT_URL/oauth/token" \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=client_credentials' \ --data-urlencode "client_id=$SCALEKIT_CLIENT_ID" \ --data-urlencode "client_secret=$SCALEKIT_CLIENT_SECRET" ``` Use the `access_token` value from the response as `$env_access_token` in the `curl` commands below. Use the payload for your auth type as the request body in the create request: **cURL** ```bash title="Terminal" # $env_access_token and $SCALEKIT_CLIENT_SECRET are secrets - keep them server-side and out of source control. # --fail-with-body makes curl exit non-zero and print the error body on a non-2xx response. curl --fail-with-body --location "$SCALEKIT_ENVIRONMENT_URL/api/v1/custom-providers" \ --header "Authorization: Bearer $env_access_token" \ --header "Content-Type: application/json" \ --data '{...}' ``` **Python** The Python SDK builds the payload with typed request objects and authenticates using your client credentials - no separate access token step is needed. It covers MCP connector auth types: OAuth (via Dynamic Client Registration), Bearer, API key, and No Auth. The example below creates an OAuth MCP connector; swap the `AuthPattern` for the auth type you need. ```python title="create_connector.py" import scalekit.client, os from dotenv import load_dotenv from scalekit.actions.types import AuthPattern, OAuthConfig, CreateCustomProviderRequest from scalekit.common.exceptions import ScalekitException load_dotenv() # Load credentials from the environment. Keep SCALEKIT_CLIENT_SECRET server-side - # never commit it or expose it in client-side code. scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) try: response = scalekit_client.actions.providers.create_custom_provider( CreateCustomProviderRequest( display_name="GitHub MCP", description="Connect to GitHub MCP", proxy_url="https://api.githubcopilot.com/mcp/", proxy_enabled=True, auth_patterns=[ AuthPattern( type="OAUTH", display_name="OAuth 2.1/DCR", description="Authenticate with GitHub MCP using browser OAuth.", is_mcp=True, oauth_config=OAuthConfig(), # pkce_enabled=True by default ) ], # Optional: icon_src="https://cdn.example.com/icon.svg", # Optional: metadata={"team": "platform"}, ) ) print("Created connector:", response.provider.identifier) except ScalekitException as err: # Handle validation errors, conflicts (duplicate name), auth failures, etc. print("Failed to create connector:", err) raise ``` A successful request returns the created connector. Next, create a connection in the Scalekit Dashboard, then continue with the standard connector flow to authorize users and call tools. ## List connectors List existing connectors before you create one, to confirm whether a connector for the upstream already exists. You also need the list to find a connector's `identifier` for update and delete requests. **cURL** ```bash title="Terminal" # $env_access_token is a secret - keep it server-side and out of source control. curl --fail-with-body --location "$SCALEKIT_ENVIRONMENT_URL/api/v1/providers?filter.provider_type=CUSTOM&page_size=1000" \ --header "Authorization: Bearer $env_access_token" ``` **Python** The Python SDK lists MCP connectors. For REST connectors, call the API directly: ```bash title="Terminal" curl --fail-with-body --location "$SCALEKIT_ENVIRONMENT_URL/api/v1/providers?filter.provider_type=CUSTOM&page_size=1000" \ --header "Authorization: Bearer $env_access_token" ``` For MCP connectors: ```python title="list_connectors.py" import scalekit.client, os from dotenv import load_dotenv from scalekit.actions.types import ListProvidersRequest from scalekit.v1.providers.providers_pb2 import ProviderType from scalekit.common.exceptions import ScalekitException load_dotenv() # Keep SCALEKIT_CLIENT_SECRET server-side - never commit it or expose it client-side. scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) try: response = scalekit_client.actions.providers.list_providers( ListProvidersRequest(provider_type=ProviderType.CUSTOM, page_size=1000) ) for provider in response.providers: print(provider.identifier, provider.display_name) except ScalekitException as err: print("Failed to list connectors:", err) raise ``` ## Update a connector Use the [List connectors](#list-connectors) API to get the connector `identifier`, then send the updated payload. Include `display_name`, `proxy_url`, and `auth_patterns` on every update, and echo back any other fields you want to keep - omitted fields are not preserved, so read the current connector first and change only what you need. **cURL** ```bash title="Terminal" # $env_access_token and $SCALEKIT_CLIENT_SECRET are secrets - keep them server-side and out of source control. curl --fail-with-body --location --request PUT "$SCALEKIT_ENVIRONMENT_URL/api/v1/custom-providers/$PROVIDER_IDENTIFIER" \ --header "Authorization: Bearer $env_access_token" \ --header "Content-Type: application/json" \ --data '{...}' ``` **Python** The Python SDK updates MCP connectors. For REST connectors, call the API directly: ```bash title="Terminal" curl --fail-with-body --location --request PUT "$SCALEKIT_ENVIRONMENT_URL/api/v1/custom-providers/$PROVIDER_IDENTIFIER" \ --header "Authorization: Bearer $env_access_token" \ --header "Content-Type: application/json" \ --data '{...}' ``` For MCP connectors: ```python title="update_connector.py" import scalekit.client, os from dotenv import load_dotenv from scalekit.actions.types import ListProvidersRequest, UpdateCustomProviderRequest from scalekit.common.exceptions import ScalekitException load_dotenv() # Keep SCALEKIT_CLIENT_SECRET server-side - never commit it or expose it client-side. scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) provider_identifier = "prov_..." # from list_providers try: # Read the current state, then echo back every field you want to keep. current = scalekit_client.actions.providers.list_providers( ListProvidersRequest(identifier=provider_identifier) ).providers[0] response = scalekit_client.actions.providers.update_custom_provider( UpdateCustomProviderRequest( identifier=current.identifier, display_name=current.display_name, proxy_url=current.proxy_url, description="Updated description", auth_patterns=current.auth_patterns, icon_src=current.icon_src, metadata=dict(current.metadata), ) ) print("Updated connector:", response.provider.identifier) except ScalekitException as err: print("Failed to update connector:", err) raise ``` ## Delete a connector Use the [List connectors](#list-connectors) API to get the connector `identifier`. If the connector is still in use, remove the related connections or connected accounts first. **cURL** ```bash title="Terminal" # $env_access_token is a secret - keep it server-side and out of source control. curl --fail-with-body --location --request DELETE "$SCALEKIT_ENVIRONMENT_URL/api/v1/custom-providers/$PROVIDER_IDENTIFIER" \ --header "Authorization: Bearer $env_access_token" ``` **Python** ```python title="delete_connector.py" import scalekit.client, os from dotenv import load_dotenv from scalekit.actions.types import DeleteCustomProviderRequest from scalekit.common.exceptions import ScalekitException load_dotenv() # Keep SCALEKIT_CLIENT_SECRET server-side - never commit it or expose it client-side. scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) try: scalekit_client.actions.providers.delete_custom_provider( DeleteCustomProviderRequest(identifier="prov_...") ) print("Connector deleted.") except ScalekitException as err: # e.g. not found, or forbidden if the connector is still in use. print("Failed to delete connector:", err) raise ``` ## Next - [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/): Create a connected account for the connector and send the user an authorization link. - [Call your connector](https://docs.scalekit.com/agentkit/bring-your-own-connector/making-tool-calls/): Make API or MCP tool calls through your connector. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a custom connector](https://docs.scalekit.com/agentkit/reference/custom-connectors/create-a-custom-connector.md) - `PUT` [Update a custom connector](https://docs.scalekit.com/agentkit/reference/custom-connectors/update-a-custom-connector.md) - `DELETE` [Delete a custom connector](https://docs.scalekit.com/agentkit/reference/custom-connectors/delete-a-custom-connector.md) --- Source: https://docs.scalekit.com/agentkit/bring-your-own-connector/making-tool-calls.md # Call your connector Call a custom AgentKit connector as a user: send REST requests through the API proxy with actions.request, or list and run the tools of a custom MCP connector. Make tool calls to your custom connector once the connector, its connection and the user's connected account are set up. The call method depends on the connector type: - **REST API connectors**: call the API through the proxy with `actions.request()`. - **MCP connectors**: list the connector's tools, then call them with `execute_tool`. Both types use the same connection, connected account, and user authorization model. ## Prerequisites Make sure: - The connector exists and is configured with the right [auth pattern](https://docs.scalekit.com/agentkit/bring-your-own-connector/create-connector) - A [connection](https://docs.scalekit.com/agentkit/connections) is configured for the connector - The [connected account](https://docs.scalekit.com/agentkit/connected-accounts) exists - The user has completed [authorization](https://docs.scalekit.com/agentkit/tools/authorize) Create a connection for your connector in the Scalekit Dashboard: > Image: Connections page showing a custom connector connection alongside built-in connectors After the user completes authorization, the connected account appears in the Connected Accounts tab: > Image: Connected Accounts tab showing an authenticated account for a custom connector ## REST API proxy calls Call a REST API connector through the API proxy, the same way you call any catalog connector. [Call any API](https://docs.scalekit.com/agentkit/tools/custom-tools/) has the full steps in Python, Node.js and cURL, including the account check and error handling. Two things are specific to a custom connector: - `path` is relative to the connector's `proxy_url`, not the app's public base URL. - The connector definition controls how Scalekit authenticates the call, so the request looks the same whether the connector uses OAuth, an API key or basic auth. **Python** ```python response = actions.request( connection_name="your-provider-connection", # from AgentKit > Connections identifier="user_123", method="GET", path="/v1/customers", # relative to the connector's proxy_url ) print(response.status_code, response.json()) ``` **Node.js** ```typescript const response = await scalekit.actions.request({ connectionName: 'your-provider-connection', // from AgentKit > Connections identifier: 'user_123', method: 'GET', path: '/v1/customers', // relative to the connector's proxy_url }); console.log(response.status, response.data); ``` ## MCP tool calling An MCP connector's tools come from the upstream MCP server, and you call them exactly like built-in tools: list the scoped tools for the connection to get their names and input schemas, then call `execute_tool`. [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/) covers both steps in Python, Node.js and cURL, and how to read the result. **Python** ```python from google.protobuf.json_format import MessageToDict page, _ = actions.tools.list_scoped_tools( identifier="user_123", filter={"connection_names": ["your-mcp-connection"]}, page_size=100, ) print([MessageToDict(t.tool)["definition"]["name"] for t in page.tools]) result = actions.execute_tool( tool_name="tool_name_from_the_list", identifier="user_123", connection_name="your-mcp-connection", tool_input={"key": "value"}, # match the tool's input_schema ) print(result.data) ``` **Node.js** ```typescript const page = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: ['your-mcp-connection'] }, pageSize: 100, }); console.log(page.tools.map((t) => t.tool?.definition?.name)); const result = await scalekit.actions.executeTool({ toolName: 'tool_name_from_the_list', identifier: 'user_123', connector: 'your-mcp-connection', toolInput: { key: 'value' }, // match the tool's input_schema }); console.log(result.data); ``` ## Next - [Environments and regions](https://docs.scalekit.com/agentkit/environments/): Create a Production environment and choose a region. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) - `GET` [List a user's scoped tools](https://docs.scalekit.com/agentkit/reference/tools/list-scoped-tools.md) --- Source: https://docs.scalekit.com/agentkit/environments.md # Environments and regions Use Development and Production environments in AgentKit: create Production, choose the US or EU region, find each environment URL and switch between them. An environment is an isolated copy of AgentKit: its own connections, connected accounts, API credentials, settings and logs. You build in a Development environment, then create a Production environment for real users. This page covers both, how regions work, and how to move between environments. ## What you get at signup Signing up creates a workspace with one **Development** environment. Development is free and meant for building and testing. Every new environment, Development or Production, starts with: - A GitHub connection named `github-connect`, which uses Scalekit's OAuth app, so you can make a tool call before you configure anything. The [Quickstart](https://docs.scalekit.com/agentkit/quickstart/) uses it. - A client ID. Generate a client secret yourself, as shown in [API credentials](https://docs.scalekit.com/agentkit/api-credentials/). Environments don't share anything. A connection, a connected account or a client secret in Development doesn't exist in Production, so you set up Production's connections again and each user connects again there. ## Development and Production | | Development | Production | | --- | --- | --- | | Environment URL | `https://.scalekit.dev` | `https://.scalekit.com` | | Billing | Always free | Billed on its own plan. See [Billing](https://docs.scalekit.com/agentkit/billing/) | | Client IDs | Start with `skc_` | Start with `prd_` | | User verification | All modes, including **Scalekit users only** for testing | **Custom user verifier** or **None** | | Custom domain | Not available | Available on paid plans. See [Custom domain](https://docs.scalekit.com/agentkit/advanced/custom-domain/) | | Quickstart in the dashboard | Shown | Hidden | In the EU region, environment URLs end in `.eu.scalekit.dev` and `.eu.scalekit.com`. ## Choose a region Scalekit runs separate US and EU regions with no shared data. Choose the region on the signup page, with **Change Region**, before you create your account. Your workspace and all its environments stay in that region, and you can't move a workspace between regions later. To use both, create a workspace in each. EU data residency is an add-on to your plan. When you create an environment in an EU workspace, the dashboard asks you to confirm it. See [Where data is hosted](https://docs.scalekit.com/agentkit/security/#where-data-is-hosted). ## Create a Production environment 1. ### Enable Production Open the environment switcher at the top left of the dashboard, next to your workspace name, and select **Enable Production**. Enter a card in the **Enable Production** form. When it's saved, the dashboard shows **Production enabled** and opens **Manage Environments**. Only Admins can add a payment method. See [Team members and roles](https://docs.scalekit.com/agentkit/team-members/). 2. ### Create the environment Select **Create Environment**, enter a **Name**, choose **Production** as the environment type, and select **Create**. You can't change an environment's type after you create it. 3. ### Set it up for real users In the new environment: - Generate a client secret and load the new environment URL, client ID and secret into your production configuration. See [API credentials](https://docs.scalekit.com/agentkit/api-credentials/). - Create your connections. For your own branding on consent screens, use [your own OAuth app](https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth/). - Set **AgentKit** > **Settings** > **User Verification** to **Custom user verifier**. See [Verify users](https://docs.scalekit.com/agentkit/user-verification/). A new Production environment starts on the Free plan. Change it in **Billing**. See [Billing](https://docs.scalekit.com/agentkit/billing/). ## Switch environments The environment switcher at the top left shows the current environment. Select it to pick another environment. Everything in the dashboard, including the **AgentKit** pages, then shows that environment. To see every environment or rename one, select **Manage Environments** in the switcher, or open **Workspace** > **Environments**. To delete an environment, contact [support](mailto:support@scalekit.com). ## Check it worked Your code talks to the environment whose URL and credentials it uses. Call the API with the Production values, for example with the [Quickstart](https://docs.scalekit.com/agentkit/quickstart/), and check that the connected account appears in **AgentKit** > **Connected Accounts** with Production selected in the switcher. ## Common problems ### Production is locked with "Requires a payment method" The workspace has no payment method yet. Select **Enable Production** in the environment switcher and add a card, or ask an Admin to. ### A connected account exists in Development but not in Production Environments share nothing. Create the connection in Production and have the user authorize again there. ### My code reaches the wrong environment The environment URL, client ID and client secret must all come from the same environment. A secret from Development fails against a Production URL. ## Next - [API credentials](https://docs.scalekit.com/agentkit/api-credentials/): Get the environment URL, client ID and a client secret for your code. - [Launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to check before real users connect. --- Source: https://docs.scalekit.com/agentkit/api-credentials.md # API credentials Find your AgentKit environment URL, client ID and client secret in the Scalekit dashboard, load them as environment variables, and rotate a secret safely. Your code authenticates to Scalekit with three values: the environment URL, a client ID and a client secret. This page shows where to find them, how to load them, and how to rotate a secret without downtime. ## Before you start - A Scalekit account. Signing up creates a Development environment. See [Environments and regions](https://docs.scalekit.com/agentkit/environments/). - The **Admin** or **Developer** role. Members can't see API credentials. See [Team members and roles](https://docs.scalekit.com/agentkit/team-members/). ## Get your credentials Each environment has its own credentials. Check that the environment switcher at the top left shows the environment you want, then: 1. Open **Developers** > **Settings** > **API Credentials**. 2. Copy the **Environment URL** and the **Client ID** from **Environment details**. 3. Under **Client secrets**, select **Generate new secret**, then **Copy to clipboard**. The secret is shown once. Scalekit stores only a hash of it, so if you lose it, generate a new one. New environments start with no secret. ## Load them in your code The SDKs read these environment variables. Keep them in a `.env` file that you don't commit, or in your platform's secret manager: ```bash title=".env" SCALEKIT_ENVIRONMENT_URL=https://.scalekit.dev SCALEKIT_CLIENT_ID= SCALEKIT_CLIENT_SECRET= ``` **Python** ```python import os from scalekit import ScalekitClient scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit_client.actions ``` **Node.js** ```ts import { ScalekitClient } from '@scalekit-sdk/node'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ); ``` **cURL** ```bash TOKEN=$(curl -sS "$SCALEKIT_ENVIRONMENT_URL/oauth/token" \ -d grant_type=client_credentials \ -d client_id="$SCALEKIT_CLIENT_ID" \ -d client_secret="$SCALEKIT_CLIENT_SECRET" | jq -r .access_token) ``` Use these credentials only on your server. Never put the client secret in browser or mobile code, where anyone can read it. ## Rotate a client secret An environment can have two secrets at a time, so you can switch to a new one before the old one stops working: 1. In **Developers** > **Settings** > **API Credentials**, select **Generate new secret** and copy it. 2. Deploy the new secret to every service that uses the old one. 3. Check that the old secret's **Last used** time stops updating. 4. Select **Delete** on the old secret and confirm. It stops working immediately. Rotate right away if a secret may have leaked, for example after it was committed to a repository or shown in logs. ## Check it worked Run the cURL example: it prints an access token. With an SDK, any call such as `actions.get_connected_account` succeeds instead of returning `401`. The secret's **Last used** time updates in the dashboard. ## Common problems ### `401` or `invalid_client` The client ID, secret and environment URL don't all come from the same environment, or the secret was deleted. Copy all three again from the environment you're calling. ### **Generate new secret** is disabled The environment already has two secrets. Delete the one you no longer use, then generate a new one. ### **Delete** is disabled It's the environment's only secret. Generate a new one first, so your code always has a working secret. ### I can't see **API Credentials** Your role doesn't include API credentials. Ask an Admin to give you the **Developer** role. ## Next - [Quickstart](https://docs.scalekit.com/agentkit/quickstart/): Make your first tool call with these credentials. - [Security and compliance](https://docs.scalekit.com/agentkit/security/): How Scalekit stores credentials, and what your app is responsible for. --- Source: https://docs.scalekit.com/agentkit/team-members.md # Team members and roles Invite teammates to your Scalekit workspace, give them the Admin, Developer, Member or No Access role, override a role per environment, or create custom roles. Invite your team to the Scalekit dashboard and give each person a role that matches their work. A role applies to every environment in the workspace unless you override it for one environment. ## Before you start - The **Admin** role. Other roles can see the team but can't change it. ## Roles | Role | For | | --- | --- | | **Admin** | Full access to everything, including members, roles and billing. | | **Developer** | Build and configure AgentKit. Can't manage members, roles or billing. | | **Member** | Read-only access to settings and logs. | | **No Access** | Can't open the dashboard. Use it to suspend someone without removing them. | What each role can do in AgentKit: | Area | Admin | Developer | Member | | --- | --- | --- | --- | | Connections: view | Yes | Yes | Yes | | Connections: create and edit | Yes | Yes | No | | Run tools in the **Playground** | Yes | No | No | | Connected accounts: view and create | Yes | Yes | No | | Connected accounts: view credentials and revoke | Yes | No | No | | API credentials: view, generate and delete secrets | Yes | Yes | No | | Tool call logs | Yes | Yes | Yes | | Webhooks: view | Yes | Yes | Yes | | Webhooks: create and edit | Yes | Yes | No | | Environments: create | Yes | No | No | | Environments: rename | Yes | Yes | No | | Billing | Yes | No | No | | Team, roles and environment access: view | Yes | Yes | Yes | | Team, roles and environment access: change | Yes | No | No | **No Access** can do none of these. ## Invite a teammate 1. Open **Workspace** > **Team Members** and select **Invite**. 2. Enter their **Email**, choose a **Role**, and select **Invite**. The person appears in **Team Members** with the role you chose. ## Change a role In **Team Members**, open the member's menu and select **Edit role**. Choose a new **Workspace role** and select **Save**. ### Give a different role in one environment Under **Environment access** in the same drawer, select **Set override** next to an environment and choose the role for that environment only. For example, make someone an Admin in Development and a Member in Production. **Reset to Default** removes the override. ## Remove a teammate In **Team Members**, open the member's menu, select **Remove Member**, and confirm with **Delete**. To block access but keep the person's record, give them **No Access** instead. ## Create a custom role When the four roles don't fit, open **Workspace** > **Roles** and select **Create role**. Give it a **Role name** and **Description**, then choose its **Permissions**, or start from a preset such as **Support** or **Billing Owner**. You can only grant permissions your own role has. Custom roles appear in the **Role** list when you invite or edit a member. ## Check it worked Ask the teammate to sign in. They see only the pages their role allows: for example, a Member doesn't see **API Credentials**, and a Developer doesn't see **Billing**. ## Common problems ### I don't see **Invite** or **Edit role** Your role can't change the team. Ask an Admin. ### A Developer can't run a tool in the Playground Running tools uses a user's live credentials, so only Admins can. Give the person the Admin role in a Development environment with an environment override, or create a custom role that includes it. ## Next - [Environments and regions](https://docs.scalekit.com/agentkit/environments/): Development and Production, and how to create Production. - [Security and compliance](https://docs.scalekit.com/agentkit/security/): What Scalekit protects, and what your app is responsible for. --- Source: https://docs.scalekit.com/agentkit/billing.md # Billing Manage your AgentKit subscription in the Scalekit dashboard: add a payment method, change a Production environment's plan, track tool calls and view invoices. Manage your subscription from the Scalekit dashboard: add a payment method, choose a plan for each Production environment, track usage and view invoices. For plan prices and what each includes, see [pricing](https://www.scalekit.com/pricing). ## How billing works - **Development is free.** Development environments are always on the Free plan and never billed. - **Each Production environment has its own plan.** Two Production environments can be on different plans. - **One payment method and billing address for the workspace.** They apply to every environment. - **AgentKit usage is measured in tool calls.** Each tool call counts once, whether it succeeds or the app returns an error. Calls rejected because the tool or connected account doesn't exist, and calls that hit a Scalekit internal error, don't count. Connected accounts aren't limited. - **Going over the Free plan upgrades you.** When a Production environment on the Free plan uses up its tool calls, it moves to the next plan automatically, as long as the workspace has a payment method. Without one, tool calls stop until you add a card. ## Before you start - The **Admin** role. Other roles can't see billing. See [Team members and roles](https://docs.scalekit.com/agentkit/team-members/). - A Production environment selected in the environment switcher. **Billing** appears in the sidebar only in Production. ## Add a payment method Open the environment switcher at the top left and select **Enable Production**. Enter a card in the form. The same card pays for every Production environment in the workspace. See [Create a Production environment](https://docs.scalekit.com/agentkit/environments/#create-a-production-environment). ## Change your plan 1. With a Production environment selected, open **Developers** > **Billing**. The **Overview** tab shows **Your plan**. 2. Select **Manage plan**, choose a plan, then select **Confirm change**. The dashboard shows **Plan updated successfully.** The change applies to this environment only. To move to a lower plan, or to cancel, contact [support](mailto:support@scalekit.com): lower plans show **Contact support to downgrade**. For Enterprise, select **Talk to sales**. ## Track usage The **Usage** tab shows the current **Billing period** and, for each metered item such as tool calls, how much you've used against your plan's allowance. ## Update billing details On the **Overview** tab, **Billing Information** shows the name, email address, address and billing period for the workspace. Select **Update billing info** or **Update payment details** to change them in the Stripe billing portal. ## View invoices The **Invoices** tab lists this environment's invoices. You can also select **View past invoices** under **Billing Information**. ## Common problems ### I don't see **Billing** in the sidebar Either a Development environment is selected, or your role isn't Admin. Switch to a Production environment, or ask an Admin. ### "You have exceeded the free usage quota" A Production environment on the Free plan used up its tool calls, and the workspace has no payment method. Tool calls fail until you add one: select **Enable Production** in the environment switcher and enter a card. ### "Your invoice is overdue" A payment failed. Select **Pay Bill** to pay it, and update your card with **Update payment details** so the next one goes through. ### "Add a payment method to see invoices." The workspace has no payment method yet. Add one as shown in [Add a payment method](#add-a-payment-method). ## Next - [Environments and regions](https://docs.scalekit.com/agentkit/environments/): Create and switch between Development and Production. - [Pricing](https://www.scalekit.com/pricing): Plan prices and what each plan includes. --- Source: https://docs.scalekit.com/agentkit/advanced/launch-checklist.md # AgentKit launch checklist Check your AgentKit integration before real users connect: Production credentials and connections, your own OAuth apps, user verification, events and security. Work through this list in your Production environment before real users connect their accounts. Each item says how to confirm it's done and links to the steps. Development and Production share nothing, so most of the work is setting up again in Production what you built in Development. ## Set up Production - [ ] **Create a Production environment.** The environment switcher at the top left lists it. See [Create a Production environment](https://docs.scalekit.com/agentkit/environments/#create-a-production-environment). - [ ] **Deploy Production credentials.** Your production servers use the Production `SCALEKIT_ENVIRONMENT_URL`, `SCALEKIT_CLIENT_ID` and `SCALEKIT_CLIENT_SECRET`, and no Development values remain. The secret's **Last used** time updates when your server calls Scalekit. See [API credentials](https://docs.scalekit.com/agentkit/api-credentials/). - [ ] **Keep the client secret on your server.** It isn't in browser or mobile code, logs or your repository. ## Recreate your connections - [ ] **Create every connection your agent uses in Production.** Each one appears in **AgentKit** > **Connections** in Production. See [Set up a connection](https://docs.scalekit.com/agentkit/connections/). - [ ] **Match connection names in your code.** The `connection_name` values your code sends match the Production connection names exactly. - [ ] **Create your custom connectors and Virtual MCP servers again, if you use them.** They belong to one environment, so create them with your Production credentials. See [Create a connector](https://docs.scalekit.com/agentkit/bring-your-own-connector/create-connector/) and [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). ## Publish your own OAuth apps Skip this section for connections that use Scalekit's credentials. A provider creates a new OAuth app in a development or testing mode that only authorizes the account that created it. The connection works while you test with your own account, then fails for your customers. For example, Airtable returns "This OAuth application cannot be used outside of development". - [ ] **Add the Production redirect URI to each app.** Copy it from the connection in Production. It differs from Development's, and it must match exactly. See [Use your own OAuth app](https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth/). - [ ] **Publish each app** out of development or testing mode, and complete the profile the provider requires first, such as a logo, terms of service and privacy policy. - [ ] **Recheck the credentials after publishing.** Publishing can change the client ID. The client ID, client secret and scopes in Scalekit match the provider's. - [ ] **Authorize with an account outside your own organization.** It connects without an "unverified app" or "needs admin approval" screen. See [Troubleshoot connection and OAuth errors](https://docs.scalekit.com/agentkit/troubleshooting/). ## Connect users safely - [ ] **Use a stable, internal ID as each user's `identifier`.** It comes from your authenticated session, never from the request body, and isn't an email address or shared across users. See [Choose an identifier](https://docs.scalekit.com/agentkit/concepts/#choose-an-identifier). - [ ] **Turn on user verification.** In Production, **AgentKit** > **Settings** > **User Verification** is set to **Custom user verifier**, your authorization links pass `user_verify_url`, and your verify route confirms the user. See [Verify users](https://docs.scalekit.com/agentkit/user-verification/). - [ ] **Send authorization links from your server.** A test user can open one, authorize, and reach `ACTIVE`. See [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/). ## Handle accounts that stop working - [ ] **Check status before tool calls, and send a new link when it isn't `ACTIVE`.** Revoke a test user's access at the provider, and your app asks them to reconnect. See [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/). - [ ] **Listen for account events.** A webhook endpoint in Production receives `connected_account.status_updated`, and your handler verifies the signature. See [Listen for account events](https://docs.scalekit.com/agentkit/account-events/). - [ ] **Delete connected accounts when your users delete their accounts.** Your account-deletion flow deletes each of the user's connected accounts, so Scalekit no longer holds their tokens. See [Delete a connected account](https://docs.scalekit.com/agentkit/connected-accounts/#delete-a-connected-account). ## Run agents safely - [ ] **If you use Virtual MCP, mint a session token for each run, on your server.** Tokens aren't stored between runs or sent to a browser. See [Mint session tokens](https://docs.scalekit.com/agentkit/mcp/session-tokens/). - [ ] **Check `mcp_server_url` before you hand a Virtual MCP server to a client.** The value you store for each server isn't empty. See [Check it worked](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/#check-it-worked). - [ ] **Handle tool call errors and rate limits.** Your agent reads the error a failed call returns, and retries with backoff when it gets a `429`. See [Errors and rate limits](https://docs.scalekit.com/agentkit/reference/errors/). - [ ] **Monitor tool calls in AgentKit > Logs.** Someone on your team checks **AgentKit** > **Logs** for failed calls after launch. See [What Scalekit stores](https://docs.scalekit.com/agentkit/security/#what-scalekit-stores). - [ ] **Leave Store connector error details off** in **AgentKit** > **Logs**, unless you're debugging a connector. With it on, logs keep error responses that can include data from the app. See [What Scalekit stores](https://docs.scalekit.com/agentkit/security/#what-scalekit-stores). - [ ] **Review what your app is responsible for.** See [Security and compliance](https://docs.scalekit.com/agentkit/security/). ## Prepare your workspace - [ ] **Give each teammate the least access they need.** Only people who manage members and billing are **Admin**. See [Team members and roles](https://docs.scalekit.com/agentkit/team-members/). - [ ] **Choose a plan for Production.** **Developers** > **Billing** shows its plan and your tool call usage. See [Billing](https://docs.scalekit.com/agentkit/billing/). ## Optional - [ ] **Serve Production from your own domain,** such as `auth.yourapp.com`, so users see it on authorization pages. See [Set up a custom domain](https://docs.scalekit.com/agentkit/advanced/custom-domain/). - [ ] **Bring your own encryption key** if your security review requires it. See [Manage encryption keys](https://docs.scalekit.com/agentkit/encryption-keys/). ## Next - [Security and compliance](https://docs.scalekit.com/agentkit/security/): What Scalekit protects, and what your app is responsible for. - [Troubleshoot connection and OAuth errors](https://docs.scalekit.com/agentkit/troubleshooting/): Find the error your user saw and how to fix it. --- Source: https://docs.scalekit.com/agentkit/security.md # Security and compliance How AgentKit protects user credentials: per-environment encryption keys in Google Cloud KMS, what Scalekit stores and for how long, regions and certifications. AgentKit holds your users' OAuth tokens and API keys, and makes API calls with them on your agent's behalf. This page explains how Scalekit protects those credentials and the data that passes through a tool call, and what your app is responsible for. Use it for a security review, or as a checklist before you onboard real users. ## Certifications - **SOC 2 Type II** and **ISO 27001** certified. - **GDPR** and **CCPA** compliant. The [Data Processing Agreement](https://www.scalekit.com/legal/data-processing-agreement) includes Standard Contractual Clauses and lists sub-processors. - **[HIPAA](https://www.scalekit.com/legal/hipaa) eligible**, with a Business Associate Agreement on Enterprise plans. Audit reports and penetration test summaries are available under NDA from the [Trust Center](https://www.scalekit.com/trust-center). ## How credentials are stored Every OAuth token and API key a user connects is encrypted before it's written to the database: - **A key per environment.** Scalekit encrypts credentials with AES-256-GCM under a data encryption key that belongs to one environment. No two environments share a key. - **Keys wrapped by Google Cloud KMS.** Each environment's key is itself encrypted by a master key in Google Cloud KMS, which is held apart from the database and the application. A copy of the database alone yields nothing usable. - **Scoped by environment.** Every read and write of stored credentials is filtered by environment, so one environment's context can't reach another's credentials. - **Rotation without downtime.** When a key rotates, new values are encrypted with the new key and Scalekit re-encrypts existing values in the background, so existing credentials keep working. To re-encrypt existing data right away, use **Re-encrypt Data** on [Encryption keys](https://docs.scalekit.com/agentkit/encryption-keys/). - **Your own key, optionally.** Hold the master key in your own Google Cloud KMS. Revoking Scalekit's access to it makes the stored credentials unusable to Scalekit. See [Encryption keys](https://docs.scalekit.com/agentkit/encryption-keys/). Scalekit never returns a user's raw tokens in API responses. Your agent calls tools with `execute_tool` or the [API proxy](https://docs.scalekit.com/agentkit/tools/custom-tools/), and Scalekit adds the token to the outgoing request. ## What Scalekit stores - **Users' OAuth tokens and API keys.** Stored: Yes, encrypted as above. Kept for: Until the connected account is deleted. - **Connection settings, including your OAuth app's client secret.** Stored: Yes. Kept for: Until the connection is deleted. - **Tool call responses.** Stored: No. Returned to your agent and not kept. Kept for: Not stored. - **Tool call inputs.** Stored: No. Sent to the app and not kept. Kept for: Not stored. - **Tool call logs.** Stored: Tool name, connection, connected account, identifier, status, error code, duration and time. Also the app's error response, when **Store connector error details** is on. Kept for: 90 days. - **Your API client secrets.** Stored: A one-way hash. The secret is shown once, when you create it. Kept for: Until you delete it. Tool call logs show in **AgentKit** > **Logs**. They never contain a tool's successful response. When a call fails, they keep Scalekit's own error message, and keep the error the app returned only when **Store connector error details** is on in **AgentKit** > **Logs**. That error can include data from the app, so leave the setting off unless you're debugging a connector. Tool call logs also contain the user's identifier, so use an internal user ID rather than an email address. See [Choose an identifier](https://docs.scalekit.com/agentkit/concepts/#choose-an-identifier). ## Where data is hosted Scalekit runs two independent regions on Google Cloud, with no shared application state: | Region | Location | | --- | --- | | US | `us-west2` (Los Angeles) | | EU | `europe-west3` (Frankfurt) | You choose the region when you sign up, and your workspace's data stays in it. See [Environments and regions](https://docs.scalekit.com/agentkit/environments/). To keep credentials, tool calls and logs inside your own infrastructure, [self-host AgentKit](https://docs.scalekit.com/agentkit/self-hosted/) with an enterprise license. ## In transit All Scalekit endpoints require HTTPS with TLS 1.3. If an app only accepts traffic from known addresses, allow Scalekit's [outbound IP addresses](https://docs.scalekit.com/reference/outbound-ip-addresses/) for your region. ## What your app is responsible for Scalekit protects credentials once a user connects. Your app decides who connects and who can act as whom: - **Verify users in production.** Set **AgentKit** > **Settings** > **User Verification** to **Custom user verifier**, so the person who approves access is the user your app meant. With **None**, anyone who opens an authorization link activates the account. See [Verify users](https://docs.scalekit.com/agentkit/user-verification/). - **Use a stable, internal identifier.** Pass your app's user ID, never an email address or anything a user can choose. Take it from your server-side session, not from the request body, so one user can't act as another. - **Keep one user's credentials with that user.** When you hand a credential to an agent runtime, such as a [Virtual MCP session token](https://docs.scalekit.com/agentkit/mcp/session-tokens/), store it per user and never share it across users. - **Keep API credentials on the server.** Load your client ID and secret from environment variables or a secret manager, never from browser or mobile code. Rotate a secret by creating a new one before you delete the old one. See [API credentials](https://docs.scalekit.com/agentkit/api-credentials/). - **Request only the scopes you need.** A narrower scope limits what a leaked token can do. See [Configure scopes](https://docs.scalekit.com/agentkit/connections/#configure-scopes). - **Give agents only the tools they need.** Start with read-only tools, and ask the user before a destructive call. Tool definitions carry `read_only_hint` and `destructive_hint` for this. See [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/). - **Verify webhook signatures.** Scalekit signs every webhook. Check the signature before you trust a payload. See [Verify each request](https://docs.scalekit.com/agentkit/account-events/#verify-each-request-and-handle-the-event). - **Limit who can use the dashboard.** Give teammates the least access their work needs. See [Team members and roles](https://docs.scalekit.com/agentkit/team-members/). ## Next - [Launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to check before real users connect. - [Verify users](https://docs.scalekit.com/agentkit/user-verification/): Confirm the person who connects is the user your app intended. --- Source: https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth.md # Use your own OAuth app Use your own OAuth app for an AgentKit connection, so users see your app's name on the consent screen and your app gets its own quota at the provider. Many OAuth connectors come with Scalekit's own OAuth app credentials, so you can create a connection and test it without registering an app with the provider. The connection form offers **Use Scalekit credentials** when a connector supports it. Connectors without Scalekit credentials need your own OAuth app from the start, as described in [Configure connections](https://docs.scalekit.com/agentkit/connections/#set-up-an-oauth-connection). You can go live on Scalekit's credentials. Your users then see Scalekit's name and branding on the consent screen, not yours. **Bring your own credentials** lets you replace Scalekit's shared OAuth credentials with your own. Once configured, users see your app name, logo, and terms on every OAuth consent screen. ## What changes when you use your own credentials - **Consent screens** display your application's name and branding - **Rate limits and quotas** are tied to your OAuth app, not Scalekit's shared pool - **Provider relationship** is direct, and your OAuth app appears in provider dashboards and audit logs - **Compliance**: useful if your organization requires a direct relationship with each OAuth provider Nothing changes in your code or the Scalekit SDK. The switch is purely a dashboard configuration on the connection. ## Configure your credentials 1. ### Copy the redirect URI from Scalekit Go to **AgentKit** > **Connections** and click **Edit** on the connection you want to update. Select **Use your own credentials**. The form expands and displays a **Redirect URI**. Copy it. 2. ### Register your OAuth app with the provider In the provider's developer console, create a new OAuth app (or use an existing one). Add the Redirect URI you copied in the previous step to the list of authorized redirect URIs. > caution: Redirect URI must match exactly > > The URI must match character-for-character. A mismatch will cause OAuth flows to fail with a redirect_uri_mismatch error. The provider gives you a **Client ID** and **Client Secret** after registration. Many provider consoles create the app in an internal or development mode by default. That works for your own testing but is not sufficient for customer consent. 3. ### Enter your credentials and save Back in Scalekit Dashboard, enter the **Client ID** and **Client Secret** from your OAuth app and click **Save**. All new OAuth flows for this connection will now use your credentials. Saving credentials only wires your app into Scalekit. Before customers can connect, promote the app to allow external accounts and validate it end-to-end — see the [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/). ## Existing connected accounts > caution: Existing connected accounts are not affected immediately > > Switching credentials does not re-authorize users who are already active. They continue using the previous credentials until they re-authorize. If you need all users to see your branding immediately, generate new authorization links and prompt them to re-authorize. ## Next - [Set up a custom domain](https://docs.scalekit.com/agentkit/advanced/custom-domain/): Serve authorization pages from your own domain. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. --- Source: https://docs.scalekit.com/agentkit/advanced/custom-domain.md # Set up a custom domain Serve your AgentKit Production environment from your own domain, such as auth.yourapp.com: add a CNAME record, verify it, and Scalekit provisions SSL. Custom domains enable you to offer a fully branded experience. By default, Scalekit assigns a unique endpoint URL, but you can replace it via CNAME configuration. The custom domain also applies to the authorization server URL shown on the OAuth consent screen during MCP authentication; users will see your branded domain instead of the auto-generated `yourapp.scalekit.com`. | Before | After | |--------|-------| | `https://yourapp.scalekit.com` | `https://auth.yourapp.com` | - **Environment:** CNAME configuration is available only for production environments - **SSL:** After successful CNAME configuration, an SSL certificate for your custom domain is automatically provisioned ## Set up your custom domain > Image: Scalekit Settings Custom Domain tab showing the subdomain URL field and a CNAME record in the DNS configuration table To set up your custom domain: 1. Go to your domain's DNS registrar 2. Add a new record to your DNS settings and select **CNAME** as the record type 3. Switch to production environment in the Scalekit dashboard 4. Copy the **Name** (your desired subdomain) from the Scalekit dashboard > Settings > Custom domains and paste it into the **Name/Label/Host** field in your DNS registrar 5. Copy the **Value** from the Scalekit dashboard > Settings > Custom domains and paste it into the **Destination/Target/Value** field in your DNS registrar 6. Save the record in your DNS registrar 7. In the Scalekit dashboard, click **Verify** CNAME record changes can take up to 72 hours to propagate, although they typically happen much sooner. ## Troubleshoot CNAME verification If there are any issues during the CNAME verification step: - Double-check your DNS configuration to ensure all values are correctly entered - Once the CNAME changes take effect, Scalekit will automatically provision an SSL certificate for your custom domain. This process can take up to 24 hours You can click on the **Check** button in the Scalekit dashboard to verify SSL certification status. If SSL provisioning takes longer than 24 hours, please contact us at [support@scalekit.com](mailto:support@scalekit.com) ## DNS registrar guides For detailed instructions on adding a CNAME record in specific registrars: - [GoDaddy: Add a CNAME record](https://www.godaddy.com/help/add-a-cname-record-19236) - [Namecheap: How to create a CNAME record](https://www.namecheap.com/support/knowledgebase/article.aspx/9646/2237/how-to-create-a-cname-record-for-your-domain) ## Next - [Manage encryption keys](https://docs.scalekit.com/agentkit/encryption-keys/): Rotate or bring your own encryption keys. - [AgentKit launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): Everything to finish before real users connect. --- Source: https://docs.scalekit.com/agentkit/encryption-keys.md # Manage encryption keys Protect AgentKit data at rest with Scalekit-managed encryption keys, or bring your own key from Google Cloud Key Management Service (KMS) and rotate it. Scalekit automatically protects data at rest with encryption keys. You can use Scalekit-managed keys or bring your own from a cloud Key Management Service (KMS) provider if your compliance policy requires you to own and control the root key. This guide shows you how to view and manage keys in the dashboard and set up Bring Your Own Key (BYOK) with GCP Cloud KMS. ## Key types | Type | Description | |------|-------------| | **Scalekit managed Data Encryption Key (DEK)** | Scalekit generates and manages the key. No setup required. | | **Bring your own key (BYOK)** | You provide a key from your own KMS. You own the key lifecycle, including rotation and revocation. | ## Key states | State | Description | |-------|-------------| | | Created, not yet in use. No data is encrypted with it. | | | The active key. All new encryption operations use it. | | | Replaced by a newer key. Existing records may still reference it until re-encryption completes. | ## Manage keys Go to **Settings** and open the **Encryption keys** tab. 1. **Create a key** Click **Create New Key** and select a provider: - **Scalekit Managed DEK**: Scalekit generates and manages the key. Click **Create**. - **BYOK - GCP Cloud KMS**: Complete [Set up BYOK with GCP KMS](#set-up-byok-with-gcp-kms) first to create the GCP key and grant access, then enter the key resource name here and click **Create**. The key is created in **Staged** state. 2. **Activate the key** Click **Activate** on a Staged key. The key becomes Primary and Scalekit uses it for all new encryption operations. > note: One primary key at a time > > Creating a new key does not replace the current Primary key. The new key stays Staged until you explicitly activate it. 3. **Re-encrypt existing data** To apply the new key to existing records, click **Re-encrypt Data**. Scalekit decrypts each existing record with the previous key and re-encrypts it with the new key. ## Set up BYOK with GCP KMS Use BYOK to register an encryption key from Google Cloud KMS that your team owns and controls. Scalekit uses the KMS API for all encrypt and decrypt operations and never stores the key material. > caution: You own the key lifecycle > > BYOK gives you direct control over your encryption key. Scalekit cannot rotate or manage the GCP key on your behalf. If the key is disabled, destroyed, or Identity and Access Management (IAM) access is revoked, Scalekit cannot encrypt new data or decrypt existing records until you restore access. ### Prerequisites - A **Google Cloud project** with the [Cloud KMS API enabled](https://cloud.google.com/kms/docs/create-encryption-keys) - **IAM permissions** to create key rings, keys, and set key-level IAM policies - The **Scalekit service account email**, shown in the Scalekit dashboard when you select BYOK - `gcloud` CLI installed and authenticated, or access to the [GCP Console](https://console.cloud.google.com/security/kms) 1. **Create a key ring** A [key ring](https://cloud.google.com/kms/docs/resource-hierarchy#key_rings) groups encryption keys by location. Key rings cannot be deleted or renamed once created. Choose the name and location carefully. **gcloud CLI** ```bash gcloud kms keyrings create "scalekit-kms-keyring" \ --location "global" \ --project "YOUR-GCP-PROJECT" ``` Replace `YOUR-GCP-PROJECT` with your Google Cloud project ID. **GCP Console** 1. Open [**Security > Key management**](https://console.cloud.google.com/security/kms), click **Key rings**. 2. Click **Create key ring**, enter `scalekit-kms-keyring` as the name. 3. Set **Location** to **Global** and click **Create**. > tip: Choose the right location > > Use `global` if your Scalekit environment has no regional data-residency requirement. For region-specific compliance, choose a location such as `us-east1` or `europe-west1`. See [Cloud KMS locations](https://cloud.google.com/kms/docs/locations). 2. **Create an encryption key** Create a symmetric AES-256-GCM key inside the key ring. **gcloud CLI** ```bash gcloud kms keys create "scalekit-kms-key" \ --location "global" \ --keyring "scalekit-kms-keyring" \ --purpose "encryption" \ --project "YOUR-GCP-PROJECT" ``` **GCP Console** 1. Click the key ring you created, then click **Create key**. 2. Enter `scalekit-kms-key` as the name. 3. Set **Protection level** to **HSM** (recommended) and click **Continue**. 4. Set **Key material** to **HSM-generated** and click **Continue**. 5. Set **Purpose** to **Symmetric encrypt/decrypt** and click **Continue**. 6. Set **Rotation period** per your policy and click **Create**. 3. **Grant Scalekit access to the key** Grant both roles at the key level to limit Scalekit's IAM access to this specific key. | Role | Purpose | |------|---------| | `roles/cloudkms.cryptoKeyEncrypterDecrypter` | Encrypt and decrypt the DEK | | `roles/cloudkms.viewer` | Read key metadata for health checks and listing | **gcloud CLI** ```bash gcloud kms keys add-iam-policy-binding "scalekit-kms-key" \ --keyring "scalekit-kms-keyring" \ --location "global" \ --project "YOUR-GCP-PROJECT" \ --member "serviceAccount:SCALEKIT-SERVICE-ACCOUNT" \ --role "roles/cloudkms.cryptoKeyEncrypterDecrypter" gcloud kms keys add-iam-policy-binding "scalekit-kms-key" \ --keyring "scalekit-kms-keyring" \ --location "global" \ --project "YOUR-GCP-PROJECT" \ --member "serviceAccount:SCALEKIT-SERVICE-ACCOUNT" \ --role "roles/cloudkms.viewer" ``` **GCP Console** 1. In the GCP Console, open **Security > Key management**, click **Key rings**, then click the key ring name. 2. Click the key name, then open the **Permissions** tab. 3. Click **Grant access**. A side panel opens. 4. In the **New principals** field, enter the Scalekit service account email. Copy it from **Settings > Encryption keys > Create New Key > BYOK - GCP Cloud KMS** in the Scalekit dashboard. 5. In the **Assign Roles** section, select **Cloud KMS CryptoKey Encrypter/Decrypter** from the first **Role** dropdown. 6. Click **+ Add another role** and select **Cloud KMS Viewer**. 7. Click **Save**. The policy update takes effect within a few minutes. > note: Key-level vs project-level IAM > > Grant these roles at the **key level** to limit Scalekit's access to only this specific key. 4. **Register the key in Scalekit** - In the Scalekit dashboard, go to **Settings** and open the **Encryption keys** tab. - Click **Create New Key** and select **BYOK - GCP Cloud KMS**. - Copy the Scalekit service account email shown in the modal. You need it for step 3 (grant Scalekit access to the key) if you have not granted IAM access yet. - Enter the fully-qualified GCP key resource name: ``` projects/YOUR-GCP-PROJECT/locations/global/keyRings/scalekit-kms-keyring/cryptoKeys/scalekit-kms-key ``` To retrieve the exact name from the CLI: ```bash gcloud kms keys describe "scalekit-kms-key" \ --keyring "scalekit-kms-keyring" \ --location "global" \ --project "YOUR-GCP-PROJECT" \ --format="value(name)" ``` - Click **Create**. The key is created in **Staged** state. **Key resource name format** The key reference must follow this format: ``` projects/{PROJECT_ID}/locations/{LOCATION}/keyRings/{KEYRING_NAME}/cryptoKeys/{KEY_NAME} ``` To retrieve the key ring path: ```bash gcloud kms keyrings describe "scalekit-kms-keyring" \ --location "global" \ --project "YOUR-GCP-PROJECT" \ --format="value(name)" ``` Append `/cryptoKeys/scalekit-kms-key` to get the full key reference. 5. **Activate the key** Click **Activate** on the staged key. The key becomes Primary and Scalekit uses it for all new encryption operations. > caution: Activation is permanent > > Once activated, you cannot deactivate the key without creating and activating a new key. Confirm your IAM grants are in place before activating. 6. **Re-encrypt existing data** Activation covers new writes automatically. Click **Re-encrypt Data** to migrate existing records. Scalekit decrypts each record with the previous key and re-encrypts it with the new key. ## Monitor with audit logs Cloud KMS logs every cryptographic operation to [Cloud Audit Logs](https://cloud.google.com/kms/docs/audit-logging). Use this filter in **Cloud Logging** to see all encrypt, decrypt, and key events for your key: ``` resource.type="cloudkms_cryptokey" ``` ## Fix common errors **Permission denied when activating or using the key** Check that both IAM bindings are applied to the correct key: ```bash gcloud kms keys get-iam-policy "scalekit-kms-key" \ --keyring "scalekit-kms-keyring" \ --location "global" \ --project "YOUR-GCP-PROJECT" ``` The output should list the Scalekit service account with both `roles/cloudkms.cryptoKeyEncrypterDecrypter` and `roles/cloudkms.viewer`. If the bindings are missing, repeat step 3. **Re-encryption reports unrecoverable rows** Unrecoverable rows are records that could not be re-encrypted, typically because the previous key version was disabled or destroyed before re-encryption completed. Contact [support@scalekit.com](mailto:support@scalekit.com) if you need help recovering affected records. **Key resource name is not accepted** Verify the format is exactly: ``` projects/PROJECT_ID/locations/LOCATION/keyRings/KEYRING_NAME/cryptoKeys/KEY_NAME ``` Run `gcloud kms keys describe` with `--format="value(name)"` to retrieve the exact string. Do not construct it manually. ## Next - [Security and compliance](https://docs.scalekit.com/agentkit/security/): What Scalekit protects, and what your app is responsible for. - [Troubleshoot connection and OAuth errors](https://docs.scalekit.com/agentkit/troubleshooting/): Find the error your user saw and how to fix it. --- Source: https://docs.scalekit.com/agentkit/self-hosted.md # Self-host AgentKit Run AgentKit in your own Kubernetes cluster with no connection to Scalekit infrastructure. What changes for connectors, OAuth apps, network access and SDKs. AgentKit runs in your own Kubernetes cluster with an enterprise license. Connections, connected accounts, the token vault, tool execution and Virtual MCP servers all run in your cluster. Once it's installed, your instance has no connection to Scalekit's infrastructure: credentials, tool calls and logs stay in your network. Use it when credentials and tool calls must stay in your network for data residency, compliance or network isolation. To get access, [talk to an engineer](https://scalekit.com/demo). Installation, configuration and upgrade guides come with your license. ## What you provide | Dependency | Requirement | | --- | --- | | Kubernetes | 1.27 or later, managed or self-managed, with Helm 3.12 or later | | Ingress | Kubernetes Gateway API or the nginx ingress controller | | PostgreSQL | 15 or later (CockroachDB is also supported) | | Redis | 6.2 or later | | SMTP | Any provider, for team invitations and sign-in email | | Domain | A domain and TLS certificate for your instance | ## Network access No traffic goes to Scalekit, but tools still call the apps they connect to. Your instance calls each connector's API and OAuth token endpoint directly, so allow outbound HTTPS from the cluster to the providers you use, such as Google, Slack or Salesforce. Your users' browsers must reach the provider's consent screen when they authorize a connected account. A connector works only if your cluster can reach its provider. Connectors for apps that run inside your network, including [your own connectors](https://docs.scalekit.com/agentkit/bring-your-own-connector/overview/), need no internet access. ## OAuth apps Scalekit's own OAuth credentials, offered as **Use Scalekit credentials** when you create a connection, belong to Scalekit's cloud and aren't available on a self-hosted instance. Create an OAuth app with each provider and use it for the connection, as in [Use your own OAuth app](https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth/). Register the redirect URI your instance shows in the connection form, which is on your own domain. ## Point your app at your instance The SDKs and the REST API work the same way. Set `SCALEKIT_ENVIRONMENT_URL` to your instance's environment URL, and use a client ID and secret from **API credentials** in your instance's dashboard at `https://app.`. See [API credentials](https://docs.scalekit.com/agentkit/api-credentials/). ## Next - [Security and compliance](https://docs.scalekit.com/agentkit/security/): How credentials are stored, and what your app is responsible for. - [Launch checklist](https://docs.scalekit.com/agentkit/advanced/launch-checklist/): What to check before real users connect accounts. --- Source: https://docs.scalekit.com/agentkit/troubleshooting.md # Troubleshoot connection and OAuth errors Fix AgentKit connection errors: account statuses, failed_to_exchange_token, session_not_found, redirect_uri_mismatch, and Google, Microsoft or Slack consent. When a user can't connect an app, or a connected account stops working, the cause is almost always one of three things: the account's status, an error from Scalekit's connection error page, or a rule the provider enforces on your OAuth app. Check the status first, then find the error text below. Each error is its own heading, so you can link straight to it. For errors from tool calls, the API proxy or a Virtual MCP server, see the **Common problems** on [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/#common-problems), [Call any API](https://docs.scalekit.com/agentkit/tools/custom-tools/#common-problems) and [Virtual MCP servers](https://docs.scalekit.com/agentkit/mcp/overview/). ## Start with diagnostics The connected account's status tells you whether the user never finished authorizing, still needs identity verification, has an expired token, or disconnected. **Python** ```python account = actions.get_connected_account( identifier="user_123", connection_name="github-connect", ).connected_account print(account.status) # ACTIVE, EXPIRED, PENDING_AUTH, PENDING_VERIFICATION or DISCONNECTED # Scopes the user granted (OAuth connections only) print((account.authorization_details or {}).get("oauth_token", {}).get("scopes")) ``` **Node.js** ```typescript import { ConnectorStatus } from '@scalekit-sdk/node'; const { connectedAccount } = await scalekit.actions.getConnectedAccount({ identifier: 'user_123', connectionName: 'github-connect', }); // The status name, such as ACTIVE or PENDING_AUTH console.log(connectedAccount && ConnectorStatus[connectedAccount.status]); // Scopes the user granted (OAuth connections only) const details = connectedAccount?.authorizationDetails?.details; console.log(details?.case === 'oauthToken' ? details.value.scopes : []); ``` **cURL** ```bash curl -sS -G "$SCALEKIT_ENVIRONMENT_URL/api/v1/connected_accounts/details" \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "connector=github-connect" \ --data-urlencode "identifier=user_123" | jq '.connected_account | {status, scopes: .authorization_details.oauth_token.scopes}' ``` If the status is `ACTIVE` but a tool call fails, run a read-only tool such as `github_user_get_authenticated`. If that works, the connection is fine and the problem is in the call: see [Use built-in tools](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/#common-problems). To learn about status changes without polling, subscribe to the `connected_account.status_updated` and `connected_account.token_refresh_failed` webhooks. See [Listen for account events](https://docs.scalekit.com/agentkit/account-events/). ## Connected account status | Status | Meaning | Fix | |--------|---------|-----| | `ACTIVE` | Credentials are valid | Nothing to fix. Tool calls work. | | `PENDING_AUTH` | The user hasn't finished authorizing | [Send an authorization link](#pending_auth) | | `PENDING_VERIFICATION` | Authorization succeeded, identity verification hasn't | [Finish user verification](#pending_verification) | | `EXPIRED` | The token expired or was revoked, and Scalekit couldn't refresh it | [Send a new authorization link](#expired) | | `DISCONNECTED` | The account was disconnected in Scalekit | [Send a new authorization link](#disconnected) | ### `PENDING_AUTH` The user hasn't finished authorizing, or you started re-authorization and they haven't completed it. Create an authorization link and send it through your app, for example as an in-app prompt or on a settings page. **Python** ```python if account.status == "PENDING_AUTH": link = actions.get_authorization_link( connection_name="github-connect", identifier="user_123", ) print(link.link) ``` **Node.js** ```typescript import { ConnectorStatus } from '@scalekit-sdk/node'; if (connectedAccount?.status === ConnectorStatus.PENDING_AUTH) { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName: 'github-connect', identifier: 'user_123', }); console.log(link); } ``` The status changes to `ACTIVE` when the user finishes, or to `PENDING_VERIFICATION` if your environment verifies users. ### `PENDING_VERIFICATION` The user authorized the app, but Scalekit is waiting for your app to confirm the user's identity before it activates the account. Complete the verification step described in [Verify user identity](https://docs.scalekit.com/agentkit/user-verification/). After verification succeeds, the status is `ACTIVE`. ### `EXPIRED` The access token expired and Scalekit couldn't refresh it, for example because the user removed your app's access at the provider, such as in their Google Account's third-party connections. Scalekit refreshes tokens on its own, so there's no refresh call to make: send the user a new authorization link, as for [`PENDING_AUTH`](#pending_auth). If the account expires again soon after every re-authorization, the connection never received a refresh token. Providers issue one only when the connection asks for offline access. Add the provider's offline-access scope, such as `offline_access`, in [Configure scopes](https://docs.scalekit.com/agentkit/connections/#configure-scopes), then have the user connect again. [Common causes](https://docs.scalekit.com/agentkit/connected-accounts/#common-causes) lists the other reasons accounts expire. ### `DISCONNECTED` The account was disconnected in Scalekit, from the dashboard or by a disconnect call from your app. Its credentials are cleared, so only re-authorization fixes it. Send a new authorization link, and until the user reconnects, show the state in your UI and stop scheduling tool calls for that account. ## Errors on Scalekit's connection page When authorization fails, Scalekit sends the user to its connection error page. The page shows a title, and its **Debug info** panel shows the `error` code, the `error_description` and an **Auth Request ID**. Find the title or the code below. > tip: Keep the Auth Request ID > > The Auth Request ID lets Scalekit support find the failed attempt in the logs. **Copy error details** on the page copies it with the error and description. ### Authorization failed (`failed_to_exchange_token`) The user approved access, but Scalekit couldn't finish the connection. Read the `error_description` to find which step failed: - **The provider rejected the token exchange.** The description holds the provider's error, such as `invalid_client` or `invalid_grant`. Check the client ID and secret on the connection: see [Invalid client or client authentication failed](#invalid-client-or-client-authentication-failed). An authorization code is valid once and briefly, so a user who waited a long time on the consent screen should start again. The page also suggests organization-level causes. The user's admin may have blocked your app with an access policy, the app may lack permission in the organization's settings, or the user's account may not have the rights to authorize it. The [provider errors](#provider-errors) below cover how Google, Microsoft and Slack report these. ### Session expired or not available (`session_not_found`) Your environment's user verification mode is **Scalekit users only**, and the person authorizing isn't signed in to the Scalekit dashboard. Scalekit uses the dashboard session to confirm who the user is, so without one it can't activate the account. - If the user is on your team, have them sign in to the Scalekit dashboard in the same browser and start the flow again. - If the user is a customer, they don't have a Scalekit login. Switch to **Custom user verifier** in **AgentKit** > **Settings** > **User Verification**, and verify users in your own app. See [Verify user identity](https://docs.scalekit.com/agentkit/user-verification/). This isn't a timeout: retrying without signing in fails the same way. ### Account mismatch (`access_denied`) The flow was refused. Two things cause it: - **The user declined consent** at the provider, or the provider denied the request. The provider sends `access_denied` back and Scalekit shows it with this title. Offer the user a way to start again. - **Scalekit's identity check failed** in **Scalekit users only** mode. The user is signed in to a different Scalekit workspace, or, for a flow started from the dashboard's playground, as a different user from the one who started it. Sign in with the expected account and start again. ### Invalid request (`invalid_request`) The request reached Scalekit without a required parameter, or with one it couldn't read, such as a missing `auth_request_id` or `state`. This happens when a link is truncated, edited or reused. Create a new authorization link and use it unchanged. If the provider returned `invalid_request`, the authorization request didn't meet its rules: check the connection's scopes and settings. ### Something went wrong (`server_error`) An unexpected error, in Scalekit or at the provider. Start the flow again after a few minutes, and check the [Scalekit status page](https://status.scalekit.com) and the provider's status page. If it persists, contact support with the Auth Request ID. Two `error_description` values point to a specific step: - **`user_verify_url not configured for verification redirect`.** Your environment uses **Custom user verifier**, but the authorization link was created without a `user_verify_url`. Pass one when you create the link, or switch modes. See [Verify user identity](https://docs.scalekit.com/agentkit/user-verification/#common-scenarios). - **`Error executing post auth hooks`.** Scalekit stored the tokens but a step after authorization failed. Start the flow again. If it fails again, contact support with the Auth Request ID. ### Other codes from the provider Scalekit passes the provider's own OAuth error through to the page, where it shows as **Unable to complete connection**. The codes are defined in [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1): | Code | Meaning | What to do | |------|---------|------------| | `unauthorized_client` | The provider doesn't allow this OAuth app to use this flow | Check the app's settings in the provider's console | | `invalid_scope` | A requested scope is unknown or not allowed for this app | Fix the scopes on the connection, then retry | | `unsupported_response_type` | The provider doesn't support the authorization code flow for this app | Check the app type in the provider's console | | `temporarily_unavailable` | The provider is overloaded or down for maintenance | Retry after a few minutes | ## OAuth app configuration errors These come from the OAuth app a connection uses, whether it's Scalekit's or [one you registered](https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth/). ### `redirect_uri_mismatch` The redirect URI registered in the provider's OAuth app doesn't match the one Scalekit sends. Providers require an exact match. 1. In the Scalekit dashboard, open **AgentKit** > **Connections** and select the connection. 2. Copy the **Redirect URI**. 3. In the provider's developer console, paste it into the OAuth app's allowed redirect URIs. 4. Save, and start the connection flow again. Most mismatches are a trailing slash (`/callback/` and `/callback`), `http` instead of `https`, or a missing port number in local development. ### Invalid client or client authentication failed The client ID or secret on the connection doesn't match the provider's OAuth app. Providers report it as `invalid_client`, inside a `failed_to_exchange_token` error. 1. Open **AgentKit** > **Connections** and select the connection. 2. Compare the **Client ID** and **Client Secret** with the OAuth app in the provider's console. 3. If the secret was rotated or expired, create a new one in the provider's console and paste it into the connection. 4. Start the connection flow again. ### `invalid_state` Scalekit checks the OAuth `state` parameter to protect against cross-site request forgery, and fails the flow with `invalid_state` when it's missing or doesn't match. The connection error page shows it as **Unable to complete connection**. Finish the flow in the same browser it started in, allow cookies, and start again from a fresh authorization link rather than a bookmarked or back-button page. ### Authorization succeeds but tools fail on a missing scope The user's token doesn't include a scope a tool needs. The app returns an error such as Slack's `missing_scope`, or a `403`. Add the scope in [Configure scopes](https://docs.scalekit.com/agentkit/connections/#configure-scopes), then have the user authorize again: existing tokens don't gain new scopes. ### The connection works but access tokens come back empty A connected account has an ID, such as `ca_...`, but its access and refresh token fields are empty. That's expected: API responses don't include provider tokens by default, which keeps them out of your app. Call tools with `execute_tool` or the [API proxy](https://docs.scalekit.com/agentkit/tools/custom-tools/), which add the user's token for you. To read the tokens themselves, contact [support](mailto:support@scalekit.com) to enable it. ## Provider errors Each provider sets its own rules for OAuth apps. These are the common ones, with links to the provider's documentation. ### It works for your account but fails for your customers A provider OAuth app often starts in a development or testing state that only its creator, or a short list of test users, can authorize. The connection works while you test it with your own account, and fails when a customer connects. Listing tools also works for you, because your own account already authorized the app. This applies to OAuth apps you registered and to connections that use Scalekit's shared credentials. To fix it, finish the provider's requirements for external use, publish the app, and connect again from an account outside your organization. [Publish your own OAuth apps](https://docs.scalekit.com/agentkit/advanced/launch-checklist/#publish-your-own-oauth-apps) in the launch checklist lists the steps. For example: - **Google** limits apps in the **Testing** publishing status to 100 test users that you list on the OAuth consent screen. Publish the app to production, and complete verification if it requests sensitive or restricted scopes. See [Google's publishing status guide](https://support.google.com/cloud/answer/15549945). - **Airtable** returns "This OAuth application cannot be used outside of development" until the integration has a privacy policy URL, a terms of service URL and a support email. See [Airtable's OAuth integration guide](https://airtable.com/developers/web/guides/oauth-integrations). - **ZoomInfo** offers partner apps for serving customers across organizations. Partner apps go through ZoomInfo's review before customers can install them. See [ZoomInfo partner apps](https://docs.gtm.ai/docs/partner-app). ### Google: unverified app screen or `admin_policy_enforced` Google shows an unverified app screen when an app that hasn't completed verification requests sensitive or restricted scopes, and caps such an app at 100 new users. Complete [Google's app verification](https://support.google.com/cloud/answer/7454865), or request fewer sensitive scopes. `admin_policy_enforced` means the user's Google Workspace administrator doesn't allow the app to access one or more requested scopes. The administrator can [mark the app as trusted](https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data) by its OAuth client ID in the Admin console's API controls. See [Google's OAuth error reference](https://developers.google.com/identity/protocols/oauth2/web-server). > note: Testing apps get short-lived refresh tokens > > Google issues refresh tokens that expire in 7 days to apps with an external user type in **Testing** status, unless they request only name, email and profile scopes. Accounts on such an app expire weekly until you publish it. See [Google's OAuth 2.0 guide](https://developers.google.com/identity/protocols/oauth2). ### Microsoft: `AADSTS65001` or "Need admin approval" `AADSTS65001` means the user or an administrator hasn't consented to the permissions the app requests. When the tenant doesn't allow users to consent, the user sees **Need admin approval**, and the error can be `AADSTS90094` (administrator consent is required). 1. Open the app registration in the Microsoft Entra admin center. 2. Check that its API permissions match the connection's scopes. 3. Have a tenant administrator select **Grant admin consent**. 4. Start the connection flow again. See Microsoft's [consent troubleshooting guide](https://learn.microsoft.com/en-us/troubleshoot/entra/entra-id/app-integration/troubleshoot-consent-issues) and [error code reference](https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes). ### Microsoft: `AADSTS50020` The account the user signed in with doesn't exist in the tenant the app expects, for example a personal account or an account from another organization signing in to a single-tenant app. Have the user sign in with their work or school account in that tenant, have an administrator add them as an external user, or make the app registration multi-tenant. See Microsoft's [error code reference](https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes). ### Slack: the workspace requires app approval When a Slack workspace only allows pre-approved apps, a member who tries to install your app submits a request instead, and the connection can't finish until an administrator approves it. Ask a workspace administrator to approve the app, or test in a workspace where you manage app settings. See [Slack's app approval guide](https://slack.com/help/articles/222386767-Manage-app-approval-for-your-workspace). ### The provider rate limits your requests The provider rejected calls because of its rate limit or quota, usually with a `429`. Retry with exponential backoff, and cache read results where you can. With [your own OAuth credentials](https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth/), your app gets its own quota at providers that set quotas per OAuth app. ## Get help Open **AgentKit** > **Connected Accounts** in the Scalekit dashboard to see the account's status and tool call logs. When you contact [support@scalekit.com](mailto:support@scalekit.com), include: - The Auth Request ID from the connection error page, if there was one - The connected account ID, or the user's identifier and the connection name - The full error text and when it happened - The steps that reproduce it ## Next - [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/): Check an account's status and ask users to reconnect. - [Listen for account events](https://docs.scalekit.com/agentkit/account-events/): Get a webhook when an account expires or is disconnected. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `GET` [Get a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/get-a-connected-account.md) - `GET` [Get a connected account's credentials](https://docs.scalekit.com/agentkit/reference/connected-accounts/get-connected-account-credentials.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) --- Source: https://docs.scalekit.com/agentkit/advanced/migrate-from-composio.md # Migrate from Composio to Scalekit Move an agent from Composio to Scalekit AgentKit: map concepts, recreate connections, have users re-authorize, then move tool calls, MCP and custom tools. This guide maps Composio concepts to their Scalekit AgentKit equivalents and walks through each migration step: SDK setup, authentication, tool execution, and MCP. Use it as a reference while porting your agent code. > note: Users must re-authorize > > OAuth refresh tokens are bound to the OAuth client that issued them. After migration, each user must complete the OAuth consent flow once through Scalekit to create a new connected account. Existing Composio tokens cannot be transferred. ## Concept mapping | Composio | Scalekit | Notes | |---|---|---| | Toolkit (e.g. `GITHUB`) | **Connector** (e.g. `github`) | Scalekit uses lowercase slugs | | Tool (e.g. `GITHUB_CREATE_ISSUE`) | **Tool** (e.g. `github_issue_create`) | Same concept, lowercase naming | | Auth config | **Connection** | OAuth app credentials, scopes, redirect URIs | | Connected account | **Connected account** | Per-user credential record | | `user_id` / entity ID | **`identifier`** | Your app's unique user ID, passed per API call | | Connect Link | **Authorization link** | OAuth redirect URL for user consent | | Session (`composio.create()`) | **`identifier`** on each call | No session to create: each call names the user | | Provider package (`composio_openai`) | `@scalekit-sdk/node` or `scalekit-sdk-python` | One SDK works with every framework | | `session.tools()` | `listScopedTools()` | Get tools a user is authorized to call | | `session.tools.execute()` | `executeTool()` | Execute a tool on behalf of a user | | `session.mcp.url` | **Virtual MCP server URL + session token** | Static server URL with a short-lived bearer token per agent run | | Custom tool (in-memory) | **Custom tool** (API Proxy) | Defined in your app code using `actions.request()` | | `executeToolRequest` (proxy) | `actions.request()` | Proxied REST API call | | Trigger | Your own scheduler or the app's webhooks | Run `executeTool()` on a schedule, or subscribe to the app's webhooks | | `COMPOSIO_SEARCH_TOOLS` | `searchTools()` | Rank tools by relevance to a task, or filter `listScopedTools()` by connection name | | `COMPOSIO_REMOTE_WORKBENCH` | Your own runtime | Run code where your agent runs | ## 1. Set up Scalekit 1. **Create a Scalekit account** Sign up at [app.scalekit.com](https://app.scalekit.com) and copy your API credentials from **Dashboard > Developers > Settings > API Credentials**. 2. **Set environment variables** ```bash SCALEKIT_CLIENT_ID=your_client_id SCALEKIT_CLIENT_SECRET=your_client_secret SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com ``` 3. **Install the SDK** **Python** ```bash pip install scalekit-sdk-python ``` **Node.js** ```bash npm install @scalekit-sdk/node ``` 4. **Initialize the client** Scalekit uses a single client instance. There is no session object — you pass `identifier` on each API call. **Python** ```python import os import scalekit.client scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) actions = scalekit_client.actions ``` **Node.js** ```typescript import { ScalekitClient } from '@scalekit-sdk/node'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET! ); ``` ## 2. Configure connections In Composio, auth configs are created programmatically or via the dashboard. In Scalekit, you configure **connections** in the dashboard. For each Composio toolkit your agent uses (Gmail, Slack, GitHub, etc.), create a corresponding connection in **Dashboard > AgentKit > Connections > Add connection**. See [Configure a connection](https://docs.scalekit.com/agentkit/connections/) for the full walkthrough. | Composio auth type | Scalekit equivalent | |---|---| | OAuth 2.0 (Composio managed) | OAuth 2.0 (use Scalekit credentials to start, then bring your own) | | OAuth 2.0 (custom) | OAuth 2.0 (bring your own credentials) | | API key | API key (user provides during connected account creation) | | Bearer token | Bearer token | | Basic auth | Basic auth | > tip: Scalekit credentials for quick testing > > Many OAuth connectors offer a **Use Scalekit credentials** option that lets you skip OAuth app registration, in development and in production. Switch to your own credentials when you want your app's name on the consent screen. See [Bring your own OAuth](https://docs.scalekit.com/agentkit/advanced/bring-your-own-oauth/). ## 3. Migrate authentication Both platforms create per-user records (connected accounts) and generate OAuth links. The SDK methods differ. #### Create a connected account and authorize **Python** **Before (Composio):** ```python # Composio handles auth in-chat or via connect link session = composio.create(user_id="user_123") # Auth is triggered automatically when a tool requires it ``` **After (Scalekit):** ```python # Create or retrieve the connected account response = actions.get_or_create_connected_account( connection_name="gmail", identifier="user_123", ) connected_account = response.connected_account # Generate an authorization link if the account is not yet active if connected_account.status != "ACTIVE": link_response = actions.get_authorization_link( connection_name="gmail", identifier="user_123", ) auth_url = link_response.link # Redirect or send auth_url to the user ``` **Node.js** **Before (Composio):** ```typescript // Composio handles auth in-chat or via connect link const session = await composio.create("user_123"); // Auth is triggered automatically when a tool requires it ``` **After (Scalekit):** ```typescript import { ConnectorStatus } from '@scalekit-sdk/node'; // Create or retrieve the connected account const response = await scalekit.actions.getOrCreateConnectedAccount({ connectionName: 'gmail', identifier: 'user_123', }); const connectedAccount = response.connectedAccount; // Generate an authorization link if the account is not yet active if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const linkResponse = await scalekit.actions.getAuthorizationLink({ connectionName: 'gmail', identifier: 'user_123', }); const authUrl = linkResponse.link; // Redirect or send authUrl to the user } ``` **Key difference:** Composio can trigger auth in-chat automatically. With Scalekit, your app explicitly creates the connected account and sends the authorization link to the user. Once the user completes the OAuth flow, the connected account becomes `ACTIVE` and your agent can execute tools. #### Check connected account status Composio tracks two statuses (`ACTIVE` and `INACTIVE`). Scalekit uses more granular states: | Scalekit status | Meaning | |---|---| | `PENDING_AUTH` | User hasn't completed authentication | | `PENDING_VERIFICATION` | Authentication complete; user verification still required | | `ACTIVE` | Credentials valid, ready for tool calls | | `EXPIRED` | Credentials expired or were revoked; re-authentication required | | `DISCONNECTED` | Account was disconnected | Check status before executing tools. If the account is not `ACTIVE`, generate a new authorization link. ## 4. Migrate tool calls #### List available tools **Python** **Before (Composio):** ```python session = composio.create(user_id="user_123") tools = session.tools() # all tools the user is authorized for ``` **After (Scalekit):** ```python tools_response, _ = scalekit_client.actions.tools.list_scoped_tools( identifier="user_123", # Required: list every connection whose tools the agent should see filter={"connection_names": ["gmail"]}, page_size=100, ) ``` **Node.js** **Before (Composio):** ```typescript const session = await composio.create("user_123"); const tools = await session.tools(); // all tools the user is authorized for ``` **After (Scalekit):** ```typescript const { tools } = await scalekit.tools.listScopedTools('user_123', { // Required: list every connection whose tools the agent should see filter: { connectionNames: ['gmail'] }, pageSize: 100, }); ``` #### Execute a tool **Python** **Before (Composio):** ```python session = composio.create(user_id="user_123") tools = session.tools() # Framework handles execution via the agent loop, or: # composio.tools.execute(tool_name="GMAIL_FETCH_MAILS", params={...}) ``` **After (Scalekit):** ```python result = actions.execute_tool( tool_name="gmail_fetch_mails", identifier="user_123", connection_name="gmail", tool_input={"query": "is:unread", "max_results": 5}, ) print(result.data) ``` **Node.js** **Before (Composio):** ```typescript const session = await composio.create("user_123"); const tools = await session.tools(); // Framework handles execution via the agent loop ``` **After (Scalekit):** ```typescript const result = await scalekit.actions.executeTool({ toolName: 'gmail_fetch_mails', identifier: 'user_123', connector: 'gmail', toolInput: { query: 'is:unread', max_results: 5 }, }); console.log(result.data); ``` **Key differences:** - Composio tool names are uppercase (`GMAIL_FETCH_MAILS`); Scalekit uses lowercase (`gmail_fetch_mails`) - Composio's session model means you don't pass `user_id` on each call. With Scalekit, pass `identifier` and the connection name on every `executeTool` call: `connection_name` in Python, `connector` in Node.js - Both return structured, LLM-ready output ### Map tool names Composio and Scalekit may name tools differently for the same connector. Browse the connector's tool list in the [Scalekit connector catalog](https://docs.scalekit.com/agentkit/connectors/) to find the exact tool names. Common patterns: | Composio tool name | Scalekit tool name | |---|---| | `GMAIL_FETCH_MAILS` | `gmail_fetch_mails` | | `SLACK_SEND_MESSAGE` | `slack_send_message` | | `GITHUB_CREATE_ISSUE` | `github_issue_create` | | `NOTION_CREATE_PAGE` | `notion_page_create` | Tool input schemas may also differ. Check each tool's parameters in the connector catalog and update your agent's tool input accordingly. ## 5. Migrate MCP Both platforms support MCP (Model Context Protocol) for framework-agnostic tool discovery and execution. **Before (Composio):** ```json { "mcpServers": { "composio": { "url": "https://backend.composio.dev/v3/mcp/{SERVER_ID}?user_id={USER_ID}", "headers": { "x-api-key": "" } } } } ``` **After (Scalekit):** Scalekit MCP uses Virtual MCP servers: 1. **Create a Virtual MCP server** — define which connections and tools the server exposes (one-time). This gives you a static `mcp_server_url`. 2. **Mint a session token** — before each agent run, call `create_session_token` for the user. Pass it as a bearer auth header. See [Virtual MCP servers](https://docs.scalekit.com/agentkit/mcp/overview/) for the full setup. ```json { "mcpServers": { "scalekit": { "url": "", "headers": { "Authorization": "Bearer " } } } } ``` **Key difference:** Composio embeds the user ID in the URL. Scalekit uses a static server URL shared across all users, with a short-lived session token per agent run for authentication. ## 6. Migrate custom tools In Scalekit, custom tools use **API Proxy mode** (`actions.request`). The proxy is available out of the box for every connector with no extra configuration. You define the tool contract in your application code and call the provider's REST endpoint through Scalekit, which injects the user's credentials automatically. | Composio approach | Scalekit approach | |---|---| | `@composio.tools.custom_tool` decorator | Define the tool in your app code | | In-memory, lost on restart | Lives in your codebase | | `executeToolRequest` for authenticated API calls | `actions.request()` — works out of the box for every connector | **Python** ```python response = actions.request( connection_name="gmail", identifier="user_123", method="GET", path="/gmail/v1/users/me/messages", ) ``` **Node.js** ```typescript const response = await scalekit.actions.request({ connectionName: 'gmail', identifier: 'user_123', method: 'GET', path: '/gmail/v1/users/me/messages', }); ``` ## 7. Add custom connectors If your agent connects to an API or MCP server that isn't in Scalekit's built-in catalog, you can add your own connector. Custom connectors support OAuth 2.0, API keys, bearer tokens, and other auth types. Once created, they work exactly like built-in connectors — same connected account flow, same `actions.request()` proxy, same MCP tool calling. See [Add your own connector](https://docs.scalekit.com/agentkit/bring-your-own-connector/overview/) for the full walkthrough. ## Checklist - [ ] Scalekit account created, API credentials saved as environment variables - [ ] Scalekit SDK installed - [ ] Connections created in the Scalekit Dashboard for each connector - [ ] Connected account creation and authorization link flow ported - [ ] Tool names updated from uppercase to lowercase - [ ] `executeTool` calls updated with `identifier` and the connection name (`connection_name` in Python, `connector` in Node.js) - [ ] Tool input schemas verified against the Scalekit connector catalog - [ ] Virtual MCP server created and session token flow implemented (if using MCP) - [ ] Custom tools ported to `actions.request()` (if applicable) - [ ] Agent tested end-to-end with a test user - [ ] Users re-authorized through Scalekit's OAuth flow ## Next - [Quickstart](https://docs.scalekit.com/agentkit/quickstart/): Make your first tool call as a user. - [Choose a framework](https://docs.scalekit.com/agentkit/examples/): Build the same agent in another framework. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) - `GET` [List a user's scoped tools](https://docs.scalekit.com/agentkit/reference/tools/list-scoped-tools.md) --- Source: https://docs.scalekit.com/cookbooks/apify-actor-per-user-oauth.md # Apify Actor with per-user OAuth via Scalekit Build an Apify Actor that uses Scalekit AgentKit so each user connects their OAuth accounts, keyed by Apify userId. An Apify Actor is a stateless serverless container. Every run starts cold — no session, no cookies, no "current user." When you want each person who runs your Actor to access their own Notion workspace, their own Gmail, or their own GitHub account, you need to map Apify's identity model onto an OAuth token store that persists across runs. Scalekit solves this with a connected-accounts model: for each `(connector, identifier)` pair, it stores one OAuth session and refreshes it automatically. This recipe builds an Actor that connects to Notion per-user and to YouTube via a shared account, using Apify's native `userId` as the per-user identifier. It also shows how to surface the OAuth consent step as a live interactive page inside the Actor run, instead of a raw link buried in JSON output. **What this recipe covers:** - **Per-user identity without input fields** — derive the connected-account identifier from `Actor.getEnv().userId` so users never type an email or ID - **Shared vs per-user connectors** — hardcode a single identifier for connectors shared across all users; derive one per user for private accounts - **Interactive auth UX** — serve a branded OAuth consent page on Apify's live-view port so users click a button rather than hunting for a raw URL - **Input schema design** — expose only the `task` field to end users; keep all auth and config internal The complete source is available in the [notion-youtube-agent](https://github.com/scalekit-developers/agentkit-apify-actor-example) repository. ## Before you start You need working OAuth credentials for each third-party API your Actor will connect to. Scalekit manages the token lifecycle, but the underlying API must be enabled and the OAuth client must exist first. **For YouTube (or any Google API):** 1. Open the [Google Cloud Console](https://console.cloud.google.com/) and select your project. 2. Go to **APIs & Services → Enabled APIs & services** and enable **YouTube Data API v3**. Without this, every tool call returns `permission_denied` even if the OAuth token is valid. 3. Go to **APIs & Services → OAuth consent screen** and add the Google accounts that will authorize the Actor under **Test users**. While the app is in "Testing" publishing status, only accounts listed here can complete the OAuth flow — all others see `Error 403: org_internal`. 4. Create an OAuth 2.0 client (**APIs & Services → Credentials → + Create credentials → OAuth client ID**). Set the application type to **Web application**. Leave the redirect URI blank for now — you'll add it from Scalekit in the next section. **For Notion:** 1. Go to [notion.so/my-integrations](https://www.notion.so/my-integrations) and create a new integration, or use Notion's OAuth setup if your Actor uses Scalekit's Notion OAuth connector. **For both connectors:** - A [Scalekit](https://app.scalekit.com) environment with API credentials (`SCALEKIT_ENVIRONMENT_URL`, `SCALEKIT_CLIENT_ID`, `SCALEKIT_CLIENT_SECRET`). - [Apify CLI](https://docs.apify.com/cli) installed: `npm install -g @apify/cli` - Node.js 18+ ### 1. Set up connections in Scalekit In the [Scalekit Dashboard](https://app.scalekit.com), go to **AgentKit → Connections** and create two connections: **YouTube connection (shared)** 1. Search for **YouTube** and click **Create**. 2. Copy the **Redirect URI** from the connection panel (it looks like `https:///sso/v1/oauth//callback`). 3. Paste it into your Google Cloud OAuth client under **Authorized redirect URIs** and save. 4. Back in Scalekit, enter the **Client ID** and **Client Secret** from your Google Cloud OAuth client. 5. Under **Scopes**, select at least `youtube.readonly`. Add `youtube` if your Actor needs write access (playlists, subscriptions). Add `yt-analytics.readonly` if you query analytics data. 6. Click **Save**. Note the **Connection name** (e.g., `youtube`) — your code must match it exactly. **Notion connection (per-user)** 1. Search for **Notion** and click **Create**. 2. Enter the **Client ID** and **Client Secret** from your Notion integration or OAuth app. 3. Scalekit pre-configures the redirect URI and scopes for Notion. Click **Save**. 4. Note the **Connection name** (e.g., `notion`). > caution: Scopes are locked at authorization time > > Scopes are locked in at authorization time. If you add scopes to a connection after a user has already authorized, their existing token does not gain the new scopes. Delete the connected account in Scalekit and have the user re-authorize to pick up the updated scopes. ### 2. Create the Apify Actor project ```bash apify create notion-youtube-agent -t project_empty cd notion-youtube-agent npm install @scalekit-sdk/node openai apify ``` Set your Scalekit credentials as Actor environment variables in the Apify Console under **Settings → Environment variables**: ```bash SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=your-secret ``` If your Actor creates new Notion pages (not just writing to existing ones), also set a default parent location. Without this, the Actor can only write to pages that already exist by exact title match. ```bash NOTION_DEFAULT_PARENT_PAGE_ID=1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d ``` To find a Notion page ID: open the page in Notion, click **Share → Copy link**. The 32-character hex string at the end of the URL is the page ID. ### 3. Derive user identity from Apify's runtime Apify exposes the identity of the account running the Actor through `Actor.getEnv()`. Use `userId` directly as the Scalekit connected-account identifier — no email field, no manual input. ```js title="src/main.js" import { Actor } from 'apify'; await Actor.init(); const { userId } = Actor.getEnv(); // userId is stable per Apify account — the same user always gets the same token. const notionIdentifier = userId; ``` `userId` is a stable opaque string that Apify sets for the account running the Actor. It persists across runs, so the first run that completes OAuth will find an active token on every subsequent run. > caution: Local development: userId is undefined > > `Actor.getEnv().userId` is `undefined` when you run the Actor locally with `apify run`. Use a hardcoded fallback for local development: > > ```js > const { userId } = Actor.getEnv(); > const notionIdentifier = userId ?? 'local-dev-user'; > ``` ### 4. Choose shared vs per-user identifiers Not every connector needs per-user isolation. A YouTube data connection used for research can be shared across all Actor runs with a hardcoded identifier. Only connectors that access private user data need per-user identifiers. ```js title="src/main.js" // Per-user: each Apify account connects their own Notion workspace. const notionIdentifier = userId; // Shared: one YouTube OAuth session used by all runs. const youtubeIdentifier = 'shared-youtube'; ``` Hardcode the shared identifier in code — do not expose it as an input field. End users should not need to know it exists. ### 5. Ensure each connector is authorized Before calling any API, check whether the connected account is active. If not, generate an authorization link and wait for the user to complete the OAuth flow. ```js title="src/notionAuth.js" import { Actor } from 'apify'; import { ConnectorStatus } from '@scalekit-sdk/node'; export async function ensureNotionConnected(scalekitActions, identifier, { pollIntervalMs = 5_000, timeoutMs = 300_000, onMagicLink = async () => {}, } = {}) { const resp = await scalekitActions.getOrCreateConnectedAccount({ connectionName: 'notion', identifier, }); const account = resp.connectedAccount ?? resp; if (account.status === ConnectorStatus.ACTIVE) { return account.id; } const { link } = await scalekitActions.getAuthorizationLink({ connectionName: 'notion', identifier, }); const markDone = await onMagicLink(link); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await sleep(pollIntervalMs); const pollResp = await scalekitActions.getOrCreateConnectedAccount({ connectionName: 'notion', identifier, }); const polled = pollResp.connectedAccount ?? pollResp; if (polled.status === ConnectorStatus.ACTIVE) { markDone?.(); await Actor.setStatusMessage('Notion authorized — proceeding.'); return polled.id; } } throw new Error(`Timed out waiting for Notion authorization.`); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } ``` The same pattern applies to every connector. Copy the function, change `connectionName`, and pass in the appropriate identifier. ### 6. Surface auth as a live interactive page Printing a raw authorization link to the console or burying it in JSON output creates a poor experience. Apify Actors can start an HTTP server on `ACTOR_WEB_SERVER_PORT` (default `4321`), and Apify automatically exposes it as a public URL while the run is active. Use this to serve a branded OAuth consent page. ```js title="src/authServer.js" import http from 'http'; import { Actor } from 'apify'; const PORT = parseInt(process.env.ACTOR_WEB_SERVER_PORT ?? '4321', 10); let server = null; export function getLiveViewUrl() { const { actorId, actorRunId } = Actor.getEnv(); return `https://${actorId}--${actorRunId}-${PORT}.runs.apify.net`; } export async function serveAuthPage(link, serviceName) { let html = buildAuthPage(link, serviceName); if (server) server.close(); server = http.createServer((_req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(html); }); server.listen(PORT); return { liveViewUrl: getLiveViewUrl(), markDone: () => { html = buildDonePage(serviceName); }, }; } function buildAuthPage(link, serviceName) { return ` Authorize ${serviceName}

🔐 Connect ${serviceName}

Click below to authorize access to your ${serviceName} account. The actor will continue automatically once you complete authorization.

Authorize ${serviceName} →
`; } function buildDonePage(serviceName) { return ` ${serviceName} Authorized

✅ ${serviceName} Authorized

Returning to task — you can close this tab.

`; } ``` 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 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. ```js title="src/main.js" import { Actor } from 'apify'; import { ScalekitClient } from '@scalekit-sdk/node'; import { ensureNotionConnected } from './notionAuth.js'; import { serveAuthPage } from './authServer.js'; await Actor.init(); const input = await Actor.getInput(); const { task } = input; const { userId } = Actor.getEnv(); const notionIdentifier = userId; const youtubeIdentifier = 'shared-youtube'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL, process.env.SCALEKIT_CLIENT_ID, process.env.SCALEKIT_CLIENT_SECRET, ); await ensureNotionConnected(scalekit.actions, notionIdentifier, { onMagicLink: async (link) => { const { liveViewUrl, markDone } = await serveAuthPage(link, 'Notion'); // Store the live view URL in OUTPUT so the Apify UI shows a clickable link. await Actor.setValue('OUTPUT', { status: 'AWAITING_NOTION_AUTH', authPageUrl: liveViewUrl, message: 'Open authPageUrl in your browser to authorize Notion.', }); await Actor.setStatusMessage(`ACTION REQUIRED: Authorize Notion → ${liveViewUrl}`); return markDone; }, }); // ... 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 > note: 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 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. ```json title=".actor/input_schema.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. > note: 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 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 **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 **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 - **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](https://docs.scalekit.com/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](https://docs.scalekit.com/agentkit/quickstart/). ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) --- Source: https://docs.scalekit.com/cookbooks/build-voice-assistant-1000-tools.md # 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](https://docs.scalekit.com/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](https://docs.scalekit.com/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_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) 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 ` 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 Function tools, the webhook works out the user on the server.** Vapi call `metadata` set in the browser is under the caller's control, so a webhook that runs `executeTool` for whatever identifier arrives there lets any caller act as any user. Instead, confirm the request came from Vapi (check the server secret you configured on the tool), then map the call to the signed-in user your backend recorded when it started the call, and use that user's identifier. Never take the identifier itself from browser-supplied metadata. This design means the same Virtual MCP server 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](https://docs.scalekit.com/agentkit/connectors/). - **Authorize for users**: Go to **AgentKit → Connected Accounts** (or during first use), authorize using your app's `identifier` (for example, `demo_user_1`). This associates the tools with that user. The connection must show **Active**. - **Custom tools**: Define additional tools in your code (see [Build custom tools](https://docs.scalekit.com/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](https://docs.scalekit.com/agentkit/connectors/)) ``` Key differences from a naive "give the LLM every tool" approach: - Tool surface is defined once in the Virtual MCP server (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 server in Scalekit 1. In the Scalekit dashboard go to **AgentKit** > **Virtual MCP Servers** and create a server. 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](https://docs.scalekit.com/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_message] # 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 (for example, `demo_user_1`): 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 ` 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`. This is a local-testing shortcut: in production the identifier never comes from the browser (see [What changes when you go live](#what-changes-when-you-go-live-production)). - The backend (or the helper script) mints a fresh short-lived session token for that identifier + your Virtual MCP server. - 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 " } } } ``` 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** ```ts title="mint-token.ts" // Mint via the REST API with a client-credentials token, 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_ENVIRONMENT_URL!.replace(/\/$/, ''); const tokenRes = await fetch( `${base}/api/v1/mcp/configs/${mcpConfigId}/tokens`, { method: 'POST', headers: { Authorization: `Bearer ${managementToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ identifier: userIdentifier, expiry: '3600s', // a protobuf Duration: seconds with an "s" suffix, 60s to 24h }), }, ); 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 server (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](https://docs.scalekit.com/agentkit/connectors/)) that you've included in your Virtual MCP server. 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 ` 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 the tools in the Virtual MCP server and strengthen the 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](https://docs.scalekit.com/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](https://docs.scalekit.com/agentkit/mcp/overview/) - [Vapi](https://vapi.ai) (see [MCP integration](https://docs.vapi.ai/tools/mcp)) - [AgentKit tool calling](https://docs.scalekit.com/agentkit/tools/scalekit-optimized-tools/) - Demo repo + recording linked above. See the [full demo source](https://github.com/scalekit-developers/vapi-scalekit-voice-demo) and the [Virtual MCP server guide](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md) --- Source: https://docs.scalekit.com/cookbooks/crewai-agentkit-email-triage.md # Build a multi-agent email triage crew with CrewAI Use CrewAI multi-agent orchestration with Scalekit-authenticated Gmail tools to scan, classify, and draft replies to emails. CrewAI's strength is multi-agent orchestration — you define specialized agents and let them collaborate on a shared workflow. But the moment those agents need to call Gmail, Slack, or GitHub on behalf of a real user, you're stuck managing OAuth tokens, refresh cycles, and per-user credential storage before you write any agent logic. Scalekit eliminates that plumbing. It stores OAuth sessions per user, refreshes tokens automatically, and exposes authenticated tools over MCP. Your CrewAI code never touches a token — it connects to a Scalekit MCP URL and gets back ready-to-use tools. This cookbook builds a three-agent email triage crew: one agent scans unread emails, another classifies them by priority, and a third drafts replies for the high-priority items. All Gmail access goes through Scalekit. **What this recipe covers:** - **Scalekit MCP integration** — get a Virtual MCP server URL and mint a session token that authenticates Gmail tools for a specific user - **CrewAI MCPServerAdapter** — connect CrewAI to the MCP server so agents can discover and call Gmail tools - **Multi-agent pipeline** — define three agents with distinct roles that run in sequence - **First-run authorization** — handle the OAuth flow when a user hasn't connected Gmail yet The complete source is available in the [crewai-scalekit-example](https://github.com/scalekit-developers/crewai-scalekit-example) repository. ### 1. Set up Gmail and a Virtual MCP server New environments do not ship with a Virtual MCP server. Create the Gmail connection and the Virtual MCP server **before** you run the sample — otherwise `list_configs` returns an empty list. In the [Scalekit Dashboard](https://app.scalekit.com): 1. Go to **AgentKit** → **Connections** → **Create Connection** and select **Gmail**. 2. Note the **Connection name** — your code references it by this exact string (for example `gmail`). 3. Go to **AgentKit** > **Virtual MCP Servers** and create a Virtual MCP server (for example `gmail-user-tools`). 4. Attach the Gmail connection and include the tools the crew needs (at least `gmail_fetch_mails`). 5. Copy the **config name** into `SCALEKIT_MCP_CONFIG_NAME` below. For the full API path (create config, mint session tokens, connect agents), see [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). ### 2. Install dependencies ```bash pip install crewai crewai-tools scalekit-sdk-python python-dotenv ``` `crewai-tools` provides `MCPServerAdapter`, which connects CrewAI to any MCP server. `scalekit-sdk-python` generates the authenticated MCP URL for each user. ### 3. Configure credentials ```bash cp .env.example .env ``` ```bash title=".env" # Scalekit — get these at app.scalekit.com → Settings → API Credentials SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=your-secret # User identifier from your application SCALEKIT_USER_IDENTIFIER=user_123 # Exact Connection name from AgentKit → Connections GMAIL_CONNECTION_NAME=gmail # Virtual MCP server name — must match the server created in step 1 SCALEKIT_MCP_CONFIG_NAME=gmail-user-tools # LLM — any OpenAI-compatible endpoint OPENAI_API_KEY=sk-... ``` ### 4. Initialize Scalekit and ensure authorization ```python import os from scalekit import ScalekitClient from dotenv import find_dotenv, load_dotenv load_dotenv(find_dotenv()) # Constructor: env_url, client_id, client_secret scalekit_client = ScalekitClient( os.environ["SCALEKIT_ENVIRONMENT_URL"], os.environ["SCALEKIT_CLIENT_ID"], os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit_client.actions USER_ID = os.getenv("SCALEKIT_USER_IDENTIFIER", "user_123") ``` Before calling any Gmail tool, check whether the user has an active connected account. If not, print an authorization link and wait for them to complete OAuth in the browser: ```python # Use the exact Connection name from AgentKit → Connections (not a guessed slug). CONNECTION_NAME = os.getenv("GMAIL_CONNECTION_NAME", "gmail") response = actions.get_or_create_connected_account( connection_name=CONNECTION_NAME, identifier=USER_ID, ) connected_account = response.connected_account # Do not start the crew until status is ACTIVE. if connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=CONNECTION_NAME, identifier=USER_ID, ) print(f"\n[{CONNECTION_NAME}] Authorization required.") print(f"Open this link:\n\n {link.link}\n") input("Press Enter after authorizing...") response = actions.get_or_create_connected_account( connection_name=CONNECTION_NAME, identifier=USER_ID, ) connected_account = response.connected_account if connected_account.status != "ACTIVE": raise RuntimeError( f"{CONNECTION_NAME} is still not ACTIVE. Complete authorization and try again." ) ``` After the first successful authorization, `get_or_create_connected_account` returns an active account on all subsequent runs. Scalekit refreshes expired tokens automatically. ### 5. Connect to Gmail tools via MCP Look up the Virtual MCP server you created in step 1, mint a session token, then pass both to `MCPServerAdapter`. If `list_configs` returns no results, stop and create the config first (dashboard or [configure-mcp-server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/)). Do not assume a default Virtual MCP server exists. ```python from crewai_tools import MCPServerAdapter from datetime import timedelta mcp_config_name = os.getenv("SCALEKIT_MCP_CONFIG_NAME", "gmail-user-tools") # Retrieve config_id by listing Virtual MCP servers filtered by name list_response = actions.mcp.list_configs(filter_name=mcp_config_name) if not list_response.configs: raise RuntimeError( f"No Virtual MCP server named '{mcp_config_name}'. " "Create one under AgentKit > Virtual MCP Servers (include Gmail tools), " "or follow https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/" ) mcp_server_url = list_response.configs[0].mcp_server_url mcp_id = list_response.configs[0].id token_response = actions.mcp.create_session_token( mcp_config_id=mcp_id, identifier=USER_ID, expiry=timedelta(hours=1), ) ``` Mint a fresh session token before each agent run. The Virtual MCP server URL is static — it stays the same across all sessions. CrewAI's `MCPServerAdapter` connects to the MCP server and discovers all available tools. It connects as soon as you create it, and the tools work only until you call `stop()`, so keep it open until the crew finishes in step 7: ```python mcp_adapter = MCPServerAdapter({ "url": mcp_server_url, "headers": {"Authorization": f"Bearer {token_response.token}"}, "transport": "streamable-http", }) # `tools` is a list of CrewAI-compatible tool objects tools = mcp_adapter.tools print(f"Discovered {len(tools)} Gmail tools") ``` ### 6. Define the agents Three agents, each with a specific role. Only the Inbox Scanner needs direct access to Gmail tools — the other agents work with the data it produces: ```python from crewai import Agent, LLM llm = LLM( model=os.getenv("LLM_MODEL", "gpt-4o"), base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.getenv("OPENAI_API_KEY"), ) scanner = Agent( role="Inbox Scanner", goal="Fetch the user's latest unread emails and extract key metadata.", backstory=( "You are an efficient assistant that reads a Gmail inbox and " "returns a structured summary of unread messages including " "subject, sender, date, and a one-line preview." ), tools=tools, # Gmail tools from MCPServerAdapter llm=llm, verbose=True, ) prioritizer = Agent( role="Email Prioritizer", goal="Classify each email by urgency: high, medium, or low.", backstory=( "You are an expert at triaging incoming messages. You consider " "sender importance, subject keywords, and time sensitivity to " "assign a priority level to each email." ), llm=llm, verbose=True, ) drafter = Agent( role="Reply Drafter", goal="Draft short, professional replies for high-priority emails.", backstory=( "You are a concise writer who drafts polite, on-point email " "replies. You focus only on high-priority items and keep each " "draft under 100 words." ), llm=llm, verbose=True, ) ``` > tip: Tool assignment > > Only assign Gmail tools to agents that need them. The Prioritizer and Drafter operate on data from the Scanner's output — they don't need direct Gmail access. This keeps the agent scopes clean and reduces unnecessary tool-calling overhead. ### 7. Define tasks and run the crew Each task describes what the agent should do and what output to expect. CrewAI runs them in sequence — each task receives the output of the previous one: ```python from crewai import Crew, Process, Task scan_task = Task( description=( "Fetch the last 5 unread emails from Gmail. For each email, " "return: subject, sender name, sender email, date, and a " "one-sentence preview of the body." ), expected_output=( "A numbered list of 5 emails with subject, sender, date, " "and preview for each." ), agent=scanner, ) prioritize_task = Task( description=( "Take the list of emails from the Inbox Scanner and classify " "each one as high, medium, or low priority. Consider sender " "importance, urgency cues in the subject, and whether the email " "requires a response." ), expected_output=( "The same list of emails, each now tagged with a priority " "level (high / medium / low) and a brief reason." ), agent=prioritizer, ) draft_task = Task( description=( "For each email marked as high priority by the Prioritizer, " "draft a short, professional reply (under 100 words). Skip " "medium and low priority emails." ), expected_output=( "A list of draft replies, one per high-priority email, " "including the original subject line and the draft text." ), agent=drafter, ) crew = Crew( agents=[scanner, prioritizer, drafter], tasks=[scan_task, prioritize_task, draft_task], process=Process.sequential, verbose=True, ) try: result = crew.kickoff() print(result) finally: # Close the MCP connection only after the crew is done with the tools mcp_adapter.stop() ``` ### 8. Run and test ```bash python agent.py ``` On first run, you see an authorization prompt: ```text [gmail] Authorization required. Open this link: https://auth.scalekit.dev/connect/... Press Enter after authorizing... ``` After completing OAuth in the browser and pressing Enter, the crew runs: ```text [ok] Session token minted Discovered 15 Gmail tools [Inbox Scanner] Fetching unread emails... [Email Prioritizer] Classifying 5 emails... [Reply Drafter] Drafting replies for 2 high-priority emails... ============================================================ CREW RESULT ============================================================ ## High-Priority Emails — Draft Replies 1. Subject: "Q1 roadmap feedback needed" From: Sarah Chen Priority: HIGH Draft: "Hi Sarah, thanks for flagging this. I'll review the roadmap doc this afternoon and share my comments by EOD." 2. Subject: "Production incident — action required" From: PagerDuty Priority: HIGH Draft: "Acknowledged. I'm looking into the alert now and will update the incident channel within 15 minutes." ``` On subsequent runs, the authorization step is skipped entirely. ## Common mistakes **Connection name mismatch** - **Symptom**: `get_or_create_connected_account` returns an error or creates a new connection instead of finding the existing one - **Cause**: The connection name in your code does not match the name in the Scalekit Dashboard exactly - **Fix**: Copy the connection name from **AgentKit → Connections** in the dashboard and paste it into your code. Case and spacing matter. **Virtual MCP server not found** - **Symptom**: `list_configs` returns an empty list - **Cause**: No Virtual MCP server exists yet, or `SCALEKIT_MCP_CONFIG_NAME` does not match the dashboard name - **Fix**: Create a Virtual MCP server under **AgentKit** > **Virtual MCP Servers** that includes Gmail tools, then set `SCALEKIT_MCP_CONFIG_NAME` to that exact name. See [Create a Virtual MCP server](https://docs.scalekit.com/agentkit/mcp/configure-mcp-server/). **Nullable schema fields crash CrewAI** - **Symptom**: `TypeError` or `ValidationError` when CrewAI parses tool schemas containing `{"type": ["string", "null"]}` - **Cause**: CrewAI's built-in JSON Schema converter does not handle nullable union types - **Fix**: Apply the monkey-patch from the [sample repo](https://github.com/scalekit-developers/crewai-scalekit-example/blob/main/agent.py#L28-L43) at the top of your script. This adds nullable type handling to `crewai.utilities.pydantic_schema_utils`. **Missing or wrong LLM API key** - **Symptom**: `AuthenticationError` or `401` from the LLM provider - **Cause**: `OPENAI_API_KEY` is not set, or points to the wrong provider - **Fix**: Verify your API key is valid. If using a LiteLLM proxy or custom endpoint, set both `OPENAI_API_KEY` and `OPENAI_BASE_URL`. The `LLM_MODEL` variable defaults to `gpt-4o` — change it to match your provider. ## Production notes **User ID from session** — The sample hardcodes `USER_ID = "user_123"`. In production, replace this with the real user identifier from your application's session or JWT. A mismatch means Scalekit looks up the wrong user's Gmail connection. **Token freshness** — Scalekit refreshes expired OAuth tokens before returning them. Mint a fresh session token before each agent run — session tokens are short-lived and scoped to a single run. **MCP server URL is static** — The Virtual MCP server URL (`mcp_server_url`) is stable and the same for all users. Cache it once per config. Only the session token is per-run. **Rate limits** — Gmail API has per-user daily quotas. If your crew runs frequently, add rate-limiting logic or use Scalekit's built-in tool pagination to limit the number of emails fetched per run. **Error handling** — In production, wrap `crew.kickoff()` in a try/except to handle LLM failures, MCP connection errors, and tool execution failures gracefully. Log the raw error for debugging. ## Next steps - **Add more connectors** — extend the crew with Slack, GitHub, or Calendar tools. Create additional connections in the dashboard, add their tools to your Virtual MCP server, and pass the expanded tool set to the Scanner agent. See [all supported connectors](https://docs.scalekit.com/agentkit/connectors/). - **Try the AgentKit CrewAI example** — for a shorter, single-agent version of this pattern, see the [CrewAI example page](https://docs.scalekit.com/agentkit/examples/crewai/). - **Explore other frameworks** — Scalekit works with LangChain, Google ADK, Vercel AI SDK, and more. See [AgentKit code samples](https://docs.scalekit.com/agentkit/examples/) for the full list. - **Handle re-authorization** — if a user revokes Gmail access, `get_or_create_connected_account` returns an inactive account. Add a re-authorization path to recover gracefully. - **Review the AgentKit quickstart** — for a broader overview of connections, tools, and MCP, see the [AgentKit quickstart](https://docs.scalekit.com/agentkit/quickstart/). ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) - `GET` [List Virtual MCP servers](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/list-virtual-mcp-servers.md) - `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md) --- Source: https://docs.scalekit.com/cookbooks/daily-briefing-agent.md # Build a daily briefing agent with Vercel AI SDK and Scalekit AgentKit Connect a TypeScript or Python agent via Vercel AI SDK and Scalekit AgentKit to Google Calendar and Gmail with authenticated tool calls. A daily briefing agent needs two things: today's calendar events and the latest unread emails. Both live behind OAuth-protected APIs, and each requires its own token, its own authorization flow, and its own refresh logic. Before you write any scheduling logic, you're already maintaining two parallel token lifecycles. Scalekit eliminates that overhead. It stores one OAuth session per connector per user, refreshes tokens automatically, and exposes **built-in tools** such as `googlecalendar_list_events` and `gmail_fetch_mails`. Your agent calls those tools through Scalekit; it never talks to the Google Calendar or Gmail REST APIs directly. **What this recipe covers:** - **Authorize once per connector** — create connected accounts for Calendar and Gmail, open the OAuth link if needed, and wait until status is `ACTIVE` - **Built-in tool calls** — `execute_tool("googlecalendar_list_events")` and `execute_tool("gmail_fetch_mails")` so Scalekit runs the provider call and returns structured data - **Wire both tools into an agent** — Vercel AI SDK (TypeScript) or Anthropic messages (Python) The complete source used here is available in the [vercel-ai-agent-toolkit](https://github.com/scalekit-developers/vercel-ai-agent-toolkit) repository, with a TypeScript implementation using the Vercel AI SDK and a Python implementation using the Anthropic SDK directly. ### 1. Set up connections in Scalekit In the [Scalekit Dashboard](https://app.scalekit.com), create two connections under **AgentKit** > **Connections** > **Create Connection**: - `googlecalendar` — Google Calendar OAuth connection - `gmail` — Gmail OAuth connection The connection names are identifiers your code references directly. They must match exactly. ### 2. Install dependencies **TypeScript** ```bash cd typescript pnpm install ``` The `typescript/package.json` includes: ```json { "dependencies": { "ai": "^4.3.15", "@ai-sdk/anthropic": "^1.2.12", "@scalekit-sdk/node": "^2.18.0", "zod": "^3.0.0", "dotenv": "^16.0.0" } } ``` **Python** ```bash cd python uv venv .venv uv pip install -r requirements.txt ``` The `python/requirements.txt` includes: ```text scalekit-sdk-python anthropic python-dotenv ``` ### 3. Configure credentials Copy the example env file and fill in your credentials: ```bash cp typescript/.env.example typescript/.env # TypeScript cp typescript/.env.example python/.env # Python (same variables) ``` ```bash title=".env" SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=your-secret ANTHROPIC_API_KEY=sk-ant-... ``` Get your Scalekit credentials at **app.scalekit.com → Settings → API Credentials**. ### 4. Initialize the Scalekit client **TypeScript** ```typescript import { ScalekitClient } from '@scalekit-sdk/node'; import { ConnectorStatus } from '@scalekit-sdk/node'; import 'dotenv/config'; // Never hard-code credentials — they would be exposed in source control. // Pull them from environment variables at runtime. const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ); const actions = scalekit.actions; const USER_ID = 'user_123'; // Replace with the real user ID from your session ``` Import `ConnectorStatus` from `@scalekit-sdk/node` (2.18.0 or later). Compare `connectedAccount.status` against `ConnectorStatus.ACTIVE` rather than the string `'ACTIVE'` — TypeScript's type system enforces this. **Python** ```python import os import json from datetime import datetime, timedelta from dotenv import load_dotenv import anthropic from scalekit import ScalekitClient load_dotenv() # Never hard-code credentials — they would be exposed in source control. # Pull them from environment variables at runtime. # Constructor: env_url, client_id, client_secret scalekit_client = ScalekitClient( os.environ["SCALEKIT_ENVIRONMENT_URL"], os.environ["SCALEKIT_CLIENT_ID"], os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit_client.actions USER_ID = "user_123" # Replace with the real user ID from your session ``` `scalekit_client.actions` is the entry point for connected-account operations and built-in tool execution. ### 5. Ensure each connector is authorized Before calling any API, check whether the user has an active connected account. If not, print an authorization link and wait for them to complete the browser OAuth flow. **TypeScript** ```typescript async function ensureConnected(connectionName: string) { let { connectedAccount } = await actions.getOrCreateConnectedAccount({ connectionName, identifier: USER_ID, }); // Do not call tools until the user finishes OAuth and status is ACTIVE. if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const { link } = await actions.getAuthorizationLink({ connectionName, identifier: USER_ID, }); console.log(`\n[${connectionName}] Authorization required.`); console.log(`Open this link:\n\n ${link}\n`); console.log('Press Enter once you have completed the OAuth flow...'); await new Promise(resolve => { process.stdin.resume(); process.stdin.once('data', () => { process.stdin.pause(); resolve(); }); }); const refreshed = await actions.getOrCreateConnectedAccount({ connectionName, identifier: USER_ID, }); connectedAccount = refreshed.connectedAccount; } if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { throw new Error(`${connectionName} is still not ACTIVE. Complete authorization and try again.`); } return connectedAccount; } ``` **Python** ```python def ensure_connected(connection_name: str): response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=USER_ID, ) connected_account = response.connected_account # Do not call tools until the user finishes OAuth and status is ACTIVE. if connected_account.status != "ACTIVE": link_response = actions.get_authorization_link( connection_name=connection_name, identifier=USER_ID, ) print(f"\n[{connection_name}] Authorization required.") print(f"Open this link:\n\n {link_response.link}\n") input("Press Enter once you have completed the OAuth flow...") response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=USER_ID, ) connected_account = response.connected_account if connected_account.status != "ACTIVE": raise RuntimeError( f"{connection_name} is still not ACTIVE. Complete authorization and try again." ) return connected_account ``` After the first successful authorization, `getOrCreateConnectedAccount` / `get_or_create_connected_account` returns an active account on all subsequent calls. Scalekit refreshes expired tokens automatically — your code never calls a token-refresh endpoint. ### 6. Fetch calendar events with a built-in tool Call `execute_tool` with `googlecalendar_list_events`. Scalekit uses the stored OAuth session, calls Google Calendar, and returns structured event data. Your agent never handles a Google access token or the Calendar REST API. **TypeScript** ```typescript import { tool } from 'ai'; import { z } from 'zod'; const getCalendarEvents = tool({ description: "Fetch today's events from Google Calendar via Scalekit", parameters: z.object({ maxResults: z.number().optional().default(5), }), execute: async ({ maxResults }) => { // Today in the machine's local time zone, as RFC 3339 timestamps const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(startOfDay); endOfDay.setDate(endOfDay.getDate() + 1); const response = await actions.executeTool({ toolName: 'googlecalendar_list_events', connectedAccountId: calendarAccount?.id, toolInput: { time_min: startOfDay.toISOString(), time_max: endOfDay.toISOString(), single_events: true, // list each occurrence of a recurring event max_results: maxResults, }, }); // Tool output lives under data — log once when integrating a new tool. return response.data ?? {}; }, }); ``` **Python** ```python def fetch_calendar_events(connected_account_id: str, max_results: int = 5) -> dict: # Today in the machine's local time zone, as RFC 3339 timestamps start_of_day = datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0) end_of_day = start_of_day + timedelta(days=1) response = actions.execute_tool( tool_name="googlecalendar_list_events", connected_account_id=connected_account_id, tool_input={ "time_min": start_of_day.isoformat(), "time_max": end_of_day.isoformat(), "single_events": True, # list each occurrence of a recurring event "max_results": max_results, }, ) # Tool output lives under data — log once when integrating a new tool. return response.data ``` ### 7. Fetch emails with a built-in tool Use the same `execute_tool` pattern for Gmail with `gmail_fetch_mails`. Scalekit runs the Gmail API call and returns structured data. **TypeScript** ```typescript const getUnreadEmails = tool({ description: 'Fetch top unread emails from Gmail via Scalekit actions', parameters: z.object({ maxResults: z.number().optional().default(5), }), execute: async ({ maxResults }) => { const response = await actions.executeTool({ toolName: 'gmail_fetch_mails', connectedAccountId: gmailAccount?.id, toolInput: { query: 'is:unread', max_results: maxResults, }, }); return response.data ?? {}; }, }); ``` **Python** ```python def fetch_unread_emails(connected_account_id: str, max_results: int = 5) -> dict: response = actions.execute_tool( tool_name="gmail_fetch_mails", connected_account_id=connected_account_id, tool_input={ "query": "is:unread", "max_results": max_results, }, ) return response.data ``` You do not need Google Calendar or Gmail API docs for the common path — tool names and parameters are consistent across Scalekit connectors. Browse [all supported agent connectors](https://docs.scalekit.com/agentkit/connectors/) for the full tool list. ### 8. Wire the agent together Pass both tools to the LLM and ask for a daily summary. **TypeScript** The TypeScript version uses the Vercel AI SDK's `generateText` with `maxSteps` to allow the LLM to call multiple tools in sequence before producing the final response. ```typescript import { generateText } from 'ai'; import { anthropic } from '@ai-sdk/anthropic'; // Authorize one connector at a time: each waits for its own Enter keypress const calendarAccount = await ensureConnected('googlecalendar'); const gmailAccount = await ensureConnected('gmail'); const today = new Date(); const { text } = await generateText({ model: anthropic('claude-sonnet-4-6'), prompt: `Give me a summary of my day for ${today.toDateString()}: list today's calendar events and my top 5 unread emails.`, tools: { getCalendarEvents, getUnreadEmails, }, maxSteps: 5, // allow the LLM to call multiple tools before responding }); console.log(text); ``` `maxSteps` controls how many tool-call rounds the LLM can make before it must return a final text response. Without it, `generateText` stops after the first tool call. **Python** The Python version uses the Anthropic SDK directly with a manual agentic loop. The loop continues until the model returns `stop_reason == "end_turn"` with no pending tool calls. ```python def run_agent(): calendar_account = ensure_connected("googlecalendar") gmail_account = ensure_connected("gmail") client = anthropic.Anthropic() today = datetime.now().strftime("%A, %B %d, %Y") tools = [ { "name": "get_calendar_events", "description": "Fetch today's events from Google Calendar via Scalekit", "input_schema": { "type": "object", "properties": {"max_results": {"type": "integer", "default": 5}}, }, }, { "name": "get_unread_emails", "description": "Fetch top unread emails from Gmail via Scalekit actions", "input_schema": { "type": "object", "properties": {"max_results": {"type": "integer", "default": 5}}, }, }, ] messages = [ { "role": "user", "content": f"Give me a summary of my day for {today}: list today's calendar events and my top 5 unread emails.", } ] while True: response = client.messages.create( model="claude-sonnet-4-6", max_tokens=1024, tools=tools, messages=messages, ) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason == "end_turn": for block in response.content: if hasattr(block, "text"): print(block.text) break tool_results = [] for block in response.content: if block.type == "tool_use": max_results = block.input.get("max_results", 5) if block.name == "get_calendar_events": result = fetch_calendar_events(calendar_account.id, max_results) elif block.name == "get_unread_emails": result = fetch_unread_emails(gmail_account.id, max_results) else: result = {"error": f"Unknown tool: {block.name}"} tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result), }) if tool_results: messages.append({"role": "user", "content": tool_results}) else: break if __name__ == "__main__": run_agent() ``` ### 9. Testing Run the agent: **TypeScript** ```bash cd typescript && pnpm start ``` **Python** ```bash cd python && .venv/bin/python index.py ``` On first run, you see two authorization prompts in sequence: ```text [googlecalendar] Authorization required. Open this link: https://auth.scalekit.dev/connect/... Press Enter once you have completed the OAuth flow... [gmail] Authorization required. Open this link: https://auth.scalekit.dev/connect/... Press Enter once you have completed the OAuth flow... ``` After both connectors are authorized, the agent fetches your data and returns a summary: ```text Here's your day for Friday, March 27, 2026: 📅 Calendar — 3 events today • 9:00 AM Team standup (30 min) • 1:00 PM Product review • 4:00 PM 1:1 with manager 📧 Unread emails — top 5 • "Q1 roadmap feedback needed" — Sarah Chen, 1h ago • "Deploy failed: production" — GitHub Actions, 2h ago • "New PR review requested" — Lin Feng, 3h ago ... ``` On subsequent runs, both authorization prompts are skipped. Scalekit returns the active session directly. ## Common mistakes **Connection name mismatch** - **Symptom**: `getOrCreateConnectedAccount` returns an error for `googlecalendar` or `gmail` - **Cause**: The connection name in the Scalekit Dashboard does not match the literal string in your code - **Fix**: Make the dashboard connection name match your code exactly, for example `googlecalendar` instead of `google-calendar` **TypeScript status compared to a string** - **Symptom**: TypeScript raises `TS2367` for `connectedAccount?.status !== 'ACTIVE'` - **Cause**: The SDK returns a `ConnectorStatus` enum, not a string literal - **Fix**: Import `ConnectorStatus` from `@scalekit-sdk/node` (2.18.0 or later) and compare against `ConnectorStatus.ACTIVE` **`maxSteps` missing in the Vercel AI SDK** - **Symptom**: `generateText` stops after the first tool call instead of returning a final summary - **Cause**: The model is not allowed to make enough tool-call rounds - **Fix**: Set `maxSteps` to at least `3`, and increase it if your workflow needs more than one tool call plus a final response **Tool called before authorization finishes** - **Symptom**: First run prints an auth link, then `execute_tool` fails because the account is not `ACTIVE` - **Cause**: The sample continued to the tool call without waiting for the browser OAuth flow - **Fix**: Block until the user completes authorization (for example `input(...)` in Python or a stdin wait in Node), then re-fetch the connected account before calling tools ## Production notes **User ID from session** — Both implementations hardcode `USER_ID = "user_123"`. In production, replace this with the real user identifier from your application's session. A mismatch means Scalekit looks up the wrong user's connected accounts. **Token freshness** — Scalekit refreshes OAuth tokens automatically before tool execution. You do not fetch provider tokens or call a refresh endpoint in application code. **First-run blocking** — The authorization prompt blocks the process until the user completes OAuth in the browser. In a web application, redirect the user to `link` instead of printing it, and handle the callback before proceeding. **`execute_tool` response shape** — Tool output lives under `response.data` (Python and Node). Keys inside `data` depend on the tool. Log the raw response once when integrating a new tool, then pass that structure to the LLM. **Rate limits** — Google Calendar and Gmail both enforce per-user quotas. If your agent runs frequently, avoid tight polling loops and cache briefing data where freshness allows. ## Next steps - **Add more connectors** — The same `ensureConnected` + `execute_tool` pattern works for any Scalekit-supported connector. Swap the connection name and tool name. See [all supported connectors](https://docs.scalekit.com/agentkit/connectors/). - **Need a raw provider call** — Prefer built-in tools first. If a tool does not cover your case, see [Build custom tools](https://docs.scalekit.com/agentkit/tools/custom-tools/) rather than extracting tokens in app code. - **Stream the response** — Replace `generateText` with `streamText` in the Vercel AI SDK to stream the LLM's summary token-by-token instead of waiting for the full response. - **Handle re-authorization** — If a user revokes access, `getOrCreateConnectedAccount` returns an inactive account. Add a re-authorization path to recover gracefully instead of crashing. - **Review the AgentKit quickstart** — For a broader overview of the connected-accounts model and supported providers, see the [AgentKit quickstart](https://docs.scalekit.com/agentkit/quickstart/). ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) --- Source: https://docs.scalekit.com/cookbooks/fastrouter-agentkit-tool-calling.md # FastRouter + Scalekit tool calling Build a Node.js agent that routes LLM calls through FastRouter and uses Scalekit for per-user OAuth tools. Build an agent that routes LLM calls through [FastRouter](https://fastrouter.ai). 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 extends that with per-user OAuth tool access, so your agent can read Gmail, create GitHub issues, or post to Slack on behalf of individual users. You can choose from [400+ connectors](https://docs.scalekit.com/agentkit/connectors/). Scalekit handles OAuth token storage, tool discovery, and tool execution for every connected service. The sample repository is **[fastrouter-scalekit-demo](https://github.com/scalekit-developers/fastrouter-scalekit-demo)** on GitHub. ## 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 for tool access** — `listScopedTools` returns per-user tool schemas ready to pass directly to FastRouter. `executeTool` runs 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 - Scalekit account with AgentKit enabled — [create one at app.scalekit.com](https://app.scalekit.com) - At least one AgentKit connection configured (Gmail, GitHub, or Slack) - FastRouter account and API key — [sign up at fastrouter.ai](https://fastrouter.ai) - Node.js 20 or later - For Python code examples: `pip install google-protobuf` (required for tool schema deserialization) ## Clone and run the sample 1. **Clone the repository and install dependencies.** ```sh git clone https://github.com/scalekit-developers/fastrouter-scalekit-demo cd fastrouter-scalekit-demo npm install ``` 2. **Copy the example environment file and fill in your credentials.** ```sh cp .env.example .env ``` Open `.env` and set these values: ```sh # Scalekit — find these in your Scalekit dashboard under API Keys SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev SCALEKIT_CLIENT_ID=your_client_id SCALEKIT_CLIENT_SECRET=your_client_secret # The AgentKit connection to use — must match a connection name in your dashboard SCALEKIT_CONNECTION_NAME=gmail # FastRouter — find your API key at fastrouter.ai/dashboard FASTROUTER_API_KEY=sk-v1-... FASTROUTER_MODEL=openai/gpt-4o-mini ``` `SCALEKIT_CONNECTION_NAME` must match the exact connection name in your Scalekit dashboard under **AgentKit → Connections**. 3. **Run the agent.** ```sh npm start ``` 4. **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 Three pieces connect FastRouter to Scalekit tools. ### 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. **Node.js** ```typescript import crypto from 'node:crypto'; import { ConnectorStatus } from '@scalekit-sdk/node'; const connectionName = process.env.SCALEKIT_CONNECTION_NAME; if (!connectionName) { throw new Error('SCALEKIT_CONNECTION_NAME is required'); } 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, identifier: 'user_123', }); // userVerifyUrl goes on the authorization link, not on the connected account if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName, identifier: 'user_123', userVerifyUrl, state, }); // Show link to user, then wait for the browser redirect callback } ``` **Python** ```python import os import secrets connection_name = os.environ["SCALEKIT_CONNECTION_NAME"] user_verify_url = "http://localhost:3000/callback" # Generate and store a state value (e.g. in a secure, HTTP-only cookie) for CSRF protection state = secrets.token_urlsafe(32) response = scalekit_client.actions.get_or_create_connected_account( connection_name=connection_name, identifier="user_123", ) # user_verify_url goes on the authorization link, not on the connected account if response.connected_account.status != "ACTIVE": link_resp = scalekit_client.actions.get_authorization_link( connection_name=connection_name, identifier="user_123", user_verify_url=user_verify_url, state=state, ) # Show link_resp.link to the user ``` `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: **Node.js** ```typescript import http from 'node:http'; async function waitForCallback(port: number, expectedState: string): Promise { 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('

Authorization complete — return to your terminal.

'); 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', }); ``` **Python** ```python # 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 ``` > tip: Production callback endpoint > > In a production web app, replace `localhost:3000/callback` with your server's callback endpoint. Scalekit redirects the browser to it with `auth_request_id` and `state` query params. Your handler must validate the state before calling `verifyConnectedAccountUser` to complete account activation. ### 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: ```typescript const { tools } = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: [connectionName] }, pageSize: 100, }); const fastRouterTools = tools .map((t) => t.tool?.definition) .filter((def): def is NonNullable => 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 Pass the tool list to FastRouter and execute each tool call through Scalekit until the model returns a response with no tool calls: ```typescript 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); break; } // 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: connectionName, 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 **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: ```sh 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: ```typescript const { tools } = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: ['gmail', 'github', 'slack'] }, }); ``` ## Next steps - **[Scalekit overview](https://docs.scalekit.com/agentkit/connections)** — Understand connected accounts, tool discovery, and tool execution in depth. - **[AgentKit connections](https://docs.scalekit.com/agentkit/connectors)** — Set up Gmail, GitHub, Slack, and other connections. - **[OpenAI example](https://docs.scalekit.com/agentkit/examples/openai)** — See the same tool-calling pattern with OpenAI directly. - **[LiteLLM inbox triage cookbook](https://docs.scalekit.com/cookbooks/litellm-agentkit-inbox-triage)** — A more complex multi-connection agent with a web approval interface. ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) - `POST` [Verify the user](https://docs.scalekit.com/agentkit/reference/authorization/verify-the-user.md) - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) - `GET` [List a user's scoped tools](https://docs.scalekit.com/agentkit/reference/tools/list-scoped-tools.md) --- Source: https://docs.scalekit.com/cookbooks/langsmith-tracing-agentkit.md # Trace AgentKit tool calls in LangSmith Add LangSmith observability to a LangChain agent that uses Scalekit AgentKit tools for Gmail, Slack, GitHub, and 400+ connectors. When you hand an LLM a set of tools — Gmail, Slack, GitHub, calendar — you need to see what happened. Which tool was called, with what arguments, what came back, and how long it took. Without that visibility, debugging a misbehaving agent means guessing. [LangSmith](https://smith.langchain.com) provides that visibility for LangChain agents. Scalekit AgentKit returns native LangChain `StructuredTool` objects, which means LangSmith traces them automatically — no wrapper code, no custom callbacks. Set two environment variables and every tool call shows up as a span in your trace. This recipe builds a Python agent that fetches Gmail messages through AgentKit and traces the entire run in LangSmith. The same pattern works with any of Scalekit's 400+ connectors. ## What you are building - **A LangChain agent** that uses Scalekit AgentKit tools to read Gmail. - **LangSmith tracing** that captures every LLM call, tool invocation, input/output, and latency as spans in a trace. - **A verification step** confirming traces appear in the LangSmith dashboard. ## Prerequisites - A Scalekit account at [app.scalekit.com](https://app.scalekit.com) with API credentials (**Settings → API Credentials**). - A **Gmail** connection configured under **AgentKit** > **Connections**. See [Configure a connection](https://docs.scalekit.com/agentkit/connections/). - A [LangSmith account](https://smith.langchain.com) and API key from **Settings → API Keys**. - An OpenAI API key, or a LiteLLM gateway URL with a virtual key. - **Python 3.10+** and **pip** or **uv**. 1. ## Install dependencies ```bash title="Terminal" pip install scalekit-sdk-python langchain-openai langsmith python-dotenv ``` `scalekit-sdk-python` includes the LangChain adapter. `langsmith` is the tracing client — importing it is enough for LangSmith to pick up traces when the environment variables are set. 2. ## Set environment variables Create a `.env` file at the project root: ```bash title=".env" # Scalekit — from app.scalekit.com → Settings → API Credentials # Threat: leaked credentials grant full API access to your Scalekit environment. # Never commit this file to version control; add .env to .gitignore. SCALEKIT_CLIENT_ID=skc_your_client_id SCALEKIT_CLIENT_SECRET=skcs_your_client_secret SCALEKIT_ENVIRONMENT_URL=https://your-subdomain.scalekit.dev # LangSmith — from smith.langchain.com → Settings → API Keys # Threat: exposed API key allows unauthorized trace reads and writes. LANGCHAIN_TRACING_V2=true LANGCHAIN_API_KEY=lsv2_your_langsmith_api_key LANGCHAIN_PROJECT=scalekit-agentkit-traces # LLM — OpenAI directly, or through a LiteLLM gateway # Threat: exposed key allows unauthorized model usage billed to your account. OPENAI_API_KEY=sk-your-openai-key ``` | Variable | Purpose | |---|---| | `LANGCHAIN_TRACING_V2` | Must be `true` to enable tracing | | `LANGCHAIN_API_KEY` | Your LangSmith API key (starts with `lsv2_`) | | `LANGCHAIN_PROJECT` | Project name in LangSmith — auto-created if it doesn't exist | > note: Using a LiteLLM gateway? > > Replace `OPENAI_API_KEY` with `LITELLM_BASE_URL` and `LITELLM_API_KEY`, then pass them to `ChatOpenAI(openai_api_base=..., openai_api_key=...)`. The tracing behavior is identical — LangSmith traces the LangChain layer, not the transport. 3. ## Connect a user to Gmail Initialize the Scalekit client and ensure the user has an active Gmail connection: ```python title="langsmith_tracing.py" import os from dotenv import load_dotenv load_dotenv() import scalekit.client scalekit_client = scalekit.client.ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), ) actions = scalekit_client.actions IDENTIFIER = "user_123" response = actions.get_or_create_connected_account( connection_name="gmail", identifier=IDENTIFIER, ) if response.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name="gmail", identifier=IDENTIFIER, ) print("Authorize Gmail:", link.link) input("Press Enter after authorizing...") else: print(f"✅ Gmail connected for {IDENTIFIER}") ``` `get_or_create_connected_account` returns an existing session if one exists. If the user hasn't authorized yet, `get_authorization_link` returns a URL the user opens in a browser. Scalekit handles the full OAuth exchange, validates the redirect callback, and stores the token. Your application never sees the `client_secret` used in the token exchange — Scalekit manages that server-side, which prevents credential leakage from frontend or agent code. 4. ## Load tools and run the agent > note: Python-only recipe > > The Scalekit LangChain adapter (`actions.langchain.get_tools()`) is Python-specific because LangChain's `StructuredTool` is a Python class. If you use the Node.js, Go, or Java SDKs, call `actions.execute_tool()` directly and trace with your framework's own observability. The `ScalekitClient` initialization pattern is the same across all four SDKs. `actions.langchain.get_tools()` returns a list of `StructuredTool` objects. Bind them to a model and run a standard tool-calling loop: ```python title="langsmith_tracing.py" from langchain_core.messages import HumanMessage, ToolMessage from langchain_openai import ChatOpenAI tools = actions.langchain.get_tools( identifier=IDENTIFIER, connection_names=["gmail"], ) tool_map = {t.name: t for t in tools} print(f"✅ Loaded {len(tools)} LangChain tools: {[t.name for t in tools[:5]]}") llm = ChatOpenAI(model="gpt-4o").bind_tools(tools) messages = [HumanMessage("Fetch my last 3 unread emails and summarize them")] while True: response = llm.invoke(messages) messages.append(response) if not response.tool_calls: print(response.content) break for tc in response.tool_calls: print(f" 🔧 Tool call: {tc['name']}") result = tool_map[tc["name"]].invoke(tc["args"]) messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"])) ``` There is no tracing-specific code here. Because `LANGCHAIN_TRACING_V2=true` is set, LangSmith automatically instruments every `invoke` call — LLM requests, tool calls, and the full message chain. 5. ## Run and verify ```bash title="Terminal" python langsmith_tracing.py ``` Expected output (the first line appears only if the account is already `ACTIVE`; on first run you will see the authorization URL instead): ```text title="Terminal" ✅ Gmail connected for user_123 ✅ Loaded 8 LangChain tools: ['gmail_fetch_mails', 'gmail_send_message', ...] 🔧 Tool call: gmail_fetch_mails Here are your 3 most recent unread emails: ... ``` Open [LangSmith](https://smith.langchain.com), select the **scalekit-agentkit-traces** project, and click the latest trace. You should see: - A **ChatOpenAI** span for the LLM call - A **gmail_fetch_mails** tool span showing the input arguments and the structured response from Gmail - Latency, token counts, and the full message chain ## Common mistakes **Traces are not appearing in LangSmith** Either `LANGCHAIN_TRACING_V2` is not `true` or `LANGCHAIN_API_KEY` is missing from the environment. **Solution:** Confirm both variables are set *before* importing any LangChain module. If you are using a `.env` file, call `load_dotenv()` at the top of the script before any other imports. You can verify with: ```python title="Terminal check" import os print(os.getenv("LANGCHAIN_TRACING_V2")) # Should print "true" print(os.getenv("LANGCHAIN_API_KEY")) # Should print "lsv2_..." ``` **Connected account stays in PENDING_AUTH** The user did not complete the OAuth flow in the browser. AgentKit waits for the user to authorize through the URL returned by `get_authorization_link`. **Solution:** Open the printed URL in a browser, complete the Google OAuth consent, and return to the terminal. The connected account status updates to `ACTIVE` after a successful callback. **Tool call fails with resource not found** The connection name in code does not match the connection name in the Scalekit dashboard, or the connected account is not active. **Solution:** Open **AgentKit** > **Connections** in the dashboard. Verify the connection name matches exactly (case-sensitive). Then check that the connected account for your identifier shows **ACTIVE** status. **Traces appear but tool spans are missing** The tools were not bound to the LLM via `.bind_tools()`, so the model is generating text instead of structured tool calls. **Solution:** Ensure you call `llm = ChatOpenAI(...).bind_tools(tools)` and that the `tools` list is not empty. Print `len(tools)` after `get_tools()` to confirm tools loaded. ## Production notes **Token refresh is automatic.** Scalekit stores OAuth tokens per user per connector and refreshes them before expiry. Your agent code never handles refresh tokens directly. **Add multiple connectors.** Pass additional connection names to `get_tools()` to load tools from Gmail, Slack, GitHub, and others in a single call. LangSmith traces all of them identically. **Trace metadata.** Use LangSmith's `@traceable` decorator or `with_config({"tags": [...]})` to add custom tags, metadata, or run names to your traces for filtering. **Cost tracking.** LangSmith captures token counts per LLM call. Combined with tool call traces, you get full-cost visibility per agent run. ## Next steps - [Configure more AgentKit connectors](https://docs.scalekit.com/agentkit/connectors/) — add Slack, GitHub, Salesforce, and 400+ others alongside Gmail. - [Virtual MCP servers](https://docs.scalekit.com/agentkit/mcp/overview/) — serve AgentKit tools over MCP for use with any MCP-compatible client. - [LangSmith evaluation](https://docs.smith.langchain.com/evaluation) — score agent responses and tool usage across test datasets. - [LangSmith trace filtering](https://docs.smith.langchain.com/how_to_guides/tracing/filter_traces_in_application) — filter traces by metadata, tags, latency, or error status. ## Related resources | Topic | Link | |---|---| | AgentKit overview | [Overview](https://docs.scalekit.com/agentkit/overview/) | | LangChain framework guide | [LangChain](https://docs.scalekit.com/agentkit/examples/langchain/) | | Connections | [Configure a connection](https://docs.scalekit.com/agentkit/connections/) | | Connected accounts | [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/) | | Sample repository | [agent-auth-examples](https://github.com/scalekit-developers/agent-auth-examples) | | LangSmith docs | [docs.smith.langchain.com](https://docs.smith.langchain.com) | ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) --- Source: https://docs.scalekit.com/cookbooks/litellm-agentkit-inbox-triage.md # Triage a Gmail inbox with AgentKit and the LiteLLM gateway Node.js inbox triage agent: classify Gmail threads, route to GitHub repos, draft issues and replies via LiteLLM, and approve before any side effects. Build an automated inbox triage agent that reads your Gmail, classifies each thread, routes it to the right GitHub repository, and notifies Slack — then waits for your approval before creating issues or sending replies. This Node.js sample uses **Scalekit AgentKit** for OAuth tool execution (Gmail, GitHub, Slack) and a **LiteLLM gateway** for model-per-stage routing. The only LiteLLM-specific config is `LITELLM_BASE_URL` and a virtual API key from the dashboard. The sample repository is **[litellm-agentkit-inbox-triage](https://github.com/scalekit-developers/litellm-agentkit-inbox-triage)** on GitHub. ## What you are building - **Gmail ingestion** — Poll for new threads using AgentKit-executed Gmail tools. A SQLite cursor prevents duplicate processing. - **Model-per-stage routing** — Each stage (`classify`, `research`, `tiebreak`, `draft`) calls the LiteLLM gateway with a different model name. Stage-to-model assignments live in `routing.yaml` at the repo root. - **Deterministic GitHub routing** — Keyword rules in `routing.yaml` pick a target repository; an optional LLM tie-breaker resolves ties. - **Research loop** — A small tool-calling loop searches related GitHub issues through AgentKit. - **Slack notification** — Posts a summary with a link to the pending decision. - **Human approval** — A localhost dashboard lists proposals. **Approve** creates the GitHub issue, sends the Gmail reply, and updates Slack. **Reject** discards without side effects. ## Automated triage pipeline New Gmail threads flow through AgentKit into a multi-stage LiteLLM pipeline, then land in SQLite as pending proposals. ```d2 title="Inbox triage pipeline: AgentKit reads Gmail, LiteLLM classifies each email, and routing rules send it to research or other steps" direction: right gmail: "Gmail inbox" { style.font-size: 16 } ingest: "Ingest\n(AgentKit)" { style.font-size: 16 } litellm: "LiteLLM pipeline" { direction: right style.font-size: 16 classify: "Classify" { style.font-size: 16 } route: "Route\n(routing.yaml)" { style.font-size: 16 } research: "Research\n(AgentKit)" { style.font-size: 16 } draft: "Draft" { style.font-size: 16 } classify -> route route -> research research -> draft } sqlite: "Store\n(SQLite)" { style.font-size: 16 } gmail -> ingest ingest -> litellm.classify litellm.draft -> sqlite ``` ## Human approval loop Proposals wait in SQLite until you review them from the dashboard. ```d2 title="Human approval loop: a proposal is stored, Slack is notified, and approving creates the issue while rejecting has no side effects" direction: right sqlite: "Pending proposal\n(SQLite)" { style.font-size: 16 } notify: "Notify Slack\n(AgentKit)" { style.font-size: 16 } ui: "Dashboard\n(localhost:3000)" { style.font-size: 16 } approve: "Approve" { style.font-size: 16 } reject: "Reject\n(no side effects)" { style.font-size: 16 } act: "Create issue\n+ send reply\n(AgentKit)" { style.font-size: 16 } sqlite -> notify notify -> ui ui -> approve: approve ui -> reject: reject approve -> act ``` ## Prerequisites - A Scalekit account at [app.scalekit.com](https://app.scalekit.com). - Ability to create **AgentKit connections** for **Gmail**, **GitHub**, and **Slack**. Connection **names** must match what you put in `.env` (see [Configure a connection](https://docs.scalekit.com/agentkit/connections/)). - A **virtual LiteLLM API key** from your LiteLLM dashboard. A small spend cap of roughly two US dollars covers a handful of test threads. - **Node.js 24 or newer** and **npm**. - An **interactive terminal** — the sample prints authorization links and waits for Enter after each connector. This recipe does not cover headless CI. 1. ## Clone the sample ```bash git clone https://github.com/scalekit-developers/litellm-agentkit-inbox-triage.git cd litellm-agentkit-inbox-triage ``` 2. ## Configure AgentKit connections 1. Open [app.scalekit.com](https://app.scalekit.com) → **AgentKit** → **Connections** → **Create Connection** for **Gmail**, **GitHub**, and **Slack**. 2. Copy each **Connection name** exactly as shown in the dashboard into `GMAIL_CONNECTION_NAME`, `GITHUB_CONNECTION_NAME`, and `SLACK_CONNECTION_NAME` in your `.env` file. 3. For **GitHub**, confirm the connection includes the **`repo`** OAuth scope (needed to create issues and search across repositories). Check **AgentKit → Connections → GitHub → Scopes** in the dashboard. See [Configure scopes](https://docs.scalekit.com/agentkit/connections/#configure-scopes) and the [GitHub connector](https://docs.scalekit.com/agentkit/connectors/github/). 4. For **Gmail** and **Slack**, follow the dashboard wizard. If your workspace restricts OAuth apps, see the connector docs: [Gmail](https://docs.scalekit.com/agentkit/connectors/gmail/), [Slack](https://docs.scalekit.com/agentkit/connectors/slack/). > caution: Dashboard only loads after all three connectors are active > > The sample calls `setupConnectors` **before** it binds the Express dashboard. You will **not** reach `http://localhost:3000` until Gmail, GitHub, and Slack each show **connector active** in the logs. 3. ## Create a LiteLLM virtual key and verify the gateway Open your LiteLLM dashboard (the LiteLLM proxy's admin UI) and create a **virtual API key** (optionally set a small budget cap for evaluation). Verify the gateway responds before continuing (load your `.env` first with `set -a && source .env && set +a`): ```bash curl -H "Authorization: Bearer $LITELLM_API_KEY" \ "$LITELLM_BASE_URL/v1/models" ``` Align `routing.yaml` → `models:` with the model IDs returned by that endpoint. 4. ## Configure and run the sample Set these variables in `.env` before running: | Variable | Where to find it | |---|---| | `SCALEKIT_ENVIRONMENT_URL` | Dashboard → **Settings** → Environment URL | | `SCALEKIT_CLIENT_ID` | Dashboard → **API Credentials** | | `SCALEKIT_CLIENT_SECRET` | Dashboard → **API Credentials** | | `GMAIL_CONNECTION_NAME` | Dashboard → **AgentKit → Connections** (exact label) | | `GITHUB_CONNECTION_NAME` | Same | | `SLACK_CONNECTION_NAME` | Same | | `LITELLM_BASE_URL` | Your LiteLLM gateway's base URL | | `LITELLM_API_KEY` | LiteLLM dashboard → virtual key value | ```bash cp .env.example .env # Fill in the variables above npm install npm run dev ``` Complete each printed **authorization URL** in the browser, then press **Enter** in the terminal after each connector. When you see **All connectors active** and **dashboard listening on `http://localhost:3000`**, send a test email to the connected Gmail account. Within roughly one poll interval (default **5 seconds**), a proposal appears in the dashboard. 5. ## Approve or reject Open **`http://localhost:3000`**. Review the classification, routed repository, related issues, and drafts. **Approve** runs GitHub issue creation, sends the Gmail reply, and updates Slack. **Reject** leaves external systems unchanged. 6. ## Extend the sample To add routing targets or swap models per stage, edit `routing.yaml` — each entry maps keyword rules to a GitHub repository and assigns a model name to each pipeline stage. To add connectors, follow the [AgentKit connections guide](https://docs.scalekit.com/agentkit/connections/) and add the new connection name to `.env`. ## Related resources | Topic | Link | |---|---| | AgentKit overview | [Overview](https://docs.scalekit.com/agentkit/overview/) | | Connections | [Configure a connection](https://docs.scalekit.com/agentkit/connections/) | | Authorization links | [Authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) | | Connected accounts | [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/) | | LiteLLM virtual keys | [Virtual keys](https://docs.litellm.ai/docs/proxy/virtual_keys) | | LiteLLM model routing | [Router](https://docs.litellm.ai/docs/routing) | | LiteLLM OpenAI-compatible API | [Proxy usage](https://docs.litellm.ai/docs/proxy/user_keys) | ## Common scenarios **Why am I seeing random tool failures or connection not found errors?** The `*_CONNECTION_NAME` variables in `.env` must match the connection labels exactly as shown in the dashboard — including capitalization and spacing. **Solution:** Open **AgentKit → Connections** in the dashboard, copy each connection name exactly, and paste it into `GMAIL_CONNECTION_NAME`, `GITHUB_CONNECTION_NAME`, and `SLACK_CONNECTION_NAME`. **Why is GitHub returning a 403 or permission error?** The GitHub AgentKit connection is missing the `repo` OAuth scope, which is required to create issues and search across repositories. **Solution:** In the dashboard, go to **AgentKit → Connections → GitHub → Scopes** and confirm `repo` is included. Re-authorize the connection if you need to add it. **Why am I seeing unknown model errors from LiteLLM?** A model name in `routing.yaml` is not available on your LiteLLM gateway instance. **Solution:** Run the following to list available models, then update `routing.yaml` → `models:` to match: ```bash curl -H "Authorization: Bearer $LITELLM_API_KEY" \ "$LITELLM_BASE_URL/v1/models" ``` **Why isn't the dashboard loading at localhost:3000?** The sample binds the dashboard only after all three connectors finish authorization. If any connector step was skipped or the terminal is still waiting for Enter, the dashboard won't start. **Solution:** Check the terminal output — the sample prints an authorization URL for each connector and waits for you to press Enter after completing it in the browser. For deeper debugging patterns, see [Troubleshoot connection and OAuth errors](https://docs.scalekit.com/agentkit/troubleshooting/). --- Source: https://docs.scalekit.com/cookbooks/livekit-agentkit-voice-tool-calling.md # 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](https://livekit.io) 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 - **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.metadata` and calls `scalekit.actions.executeTool()` directly when the LLM decides to check the calendar. - **A pattern that generalizes**: swap `googlecalendar_list_events` for 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](https://github.com/scalekit-developers/livekit-scalekit-voice-agent). ## Prerequisites - A [LiveKit Cloud](https://cloud.livekit.io) project — copy `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` from **Settings → Keys**. No `lk` CLI login is required; the app and the agent both read these three values from the environment. - A Scalekit account with **AgentKit** enabled and a `googlecalendar` connection that shows as **Active** for the identifier you'll test with. See [Configure a connection](https://docs.scalekit.com/agentkit/connections/). - **Node.js 20.11+** — the agent worker uses `import.meta.filename`, which older Node versions don't have. 1. ## Install dependencies This is a two-process app: a Next.js frontend/API layer, and a standalone agent worker that runs as a separate `tsx` process. ```bash title="Terminal" npm install @scalekit-sdk/node livekit-server-sdk livekit-client @livekit/components-react @livekit/agents zod next react react-dom npm install -D tsx typescript ``` `@livekit/agents` is the Node Agents SDK — it ships the `voice.AgentSession` pipeline, the `defineAgent`/`cli.runApp` worker entrypoint, and the `llm.tool()` helper for function tools. `@scalekit-sdk/node` is the same Scalekit client either side of this app uses to call AgentKit. 2. ## Set environment variables Create `.env.local` at the project root: ```bash title=".env.local" # Scalekit — Settings → API Credentials in your Scalekit dashboard SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com SCALEKIT_CLIENT_ID=skc_your_client_id SCALEKIT_CLIENT_SECRET=your_client_secret # Development only: the identifier getSignedInUser() returns until you connect your auth TEST_IDENTIFIER=user@example.com # LiveKit Cloud — Settings → Keys LIVEKIT_URL=wss://your-project.livekit.cloud LIVEKIT_API_KEY=your_api_key LIVEKIT_API_SECRET=your_api_secret ``` `TEST_IDENTIFIER` stands 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](#production-notes). 3. ## 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 `metadata` field 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: ```typescript title="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: ```typescript title="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 under `agentName` — you'll register the same string in the next step. `metadata` is a plain JSON string; LiveKit stores it and hands it to the worker's job context untouched. The browser only ever receives `token` (a room-join JWT) and `roomName` — never a Scalekit credential. > caution: This is the entire security boundary > > The agent runs tools as whatever identifier this route puts in the dispatch metadata, so the identifier must come from the signed-in user on the server. Never read it from the request body, a query string or anything else the client can set: if you do, any caller can run tools on another user's connected accounts. Only your server can create a dispatch, because it needs the LiveKit API secret, so the worker can trust the metadata it receives. 4. ## 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: ```typescript title="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 => { 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 no `name` field inside the call. The LLM sees the tool as `googlecalendar_list_events` because that's the key in the `tools: { googlecalendar_list_events }` map. - **`voice.Agent` is a plain constructor** — `new voice.Agent({ instructions, tools })`. There's no static factory method. - **The three model strings are [LiveKit Inference](https://docs.livekit.io/agents/) 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 string `AgentDispatchClient.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. 5. ## Run both processes ```bash title="Terminal 1 — Next.js app" npm run dev ``` ```bash title="Terminal 2 — agent worker" npx tsx watch --env-file=.env.local agent/src/agent.ts dev ``` `--env-file` matters here: Next.js loads `.env.local` automatically, but a plain `tsx` process doesn't unless you tell it to. ## Testing Confirm the identity actually reaches the agent before wiring up a browser. Hit the dispatch route directly. In development, `getSignedInUser()` returns `TEST_IDENTIFIER`: ```bash title="Terminal" curl -X POST http://localhost:3000/api/livekit/start ``` The agent worker's terminal should log that identifier within a couple of seconds: ```text title="Terminal 2 output" [scalekit-voice-agent] job=AJ_giyT9fjSxPg8 room=voice-2eb7cc3d-... identifier=user@example.com ``` If that line doesn't appear, the agent never received the dispatch — see [Common mistakes](#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 **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 **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](https://docs.scalekit.com/agentkit/connectors/) and the identity-passing mechanism is unchanged. ## Next steps - [Configure more connectors](https://docs.scalekit.com/agentkit/connectors/) — extend beyond Google Calendar to Gmail, Slack, or GitHub. - [Connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/) — check connection status and revoke access programmatically. - [AgentKit quickstart](https://docs.scalekit.com/agentkit/quickstart/) — connect your first user in under five minutes. - [LiveKit Agents docs](https://docs.livekit.io/agents/) — turn detection, interruption handling, and telephony/SIP. ## Related resources | Topic | Link | |---|---| | AgentKit overview | [Overview](https://docs.scalekit.com/agentkit/overview/) | | All connectors | [Connectors](https://docs.scalekit.com/agentkit/connectors/) | | Connected accounts | [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/) | | Sample repository | [livekit-scalekit-voice-agent](https://github.com/scalekit-developers/livekit-scalekit-voice-agent) | | LiveKit Agents docs | [docs.livekit.io/agents](https://docs.livekit.io/agents/) | ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) --- Source: https://docs.scalekit.com/cookbooks/mastra-agentkit.md # Build a Mastra agent with Scalekit AgentKit tools Give a Mastra agent access to Gmail and 400+ connectors through Scalekit AgentKit — zero manual OAuth handling. A [Mastra](https://mastra.ai) agent that reads emails needs a Gmail OAuth token. An agent that also posts to Slack needs a second token. Each tool means another OAuth flow, another token store, another refresh cycle. Before you write any agent logic, you are already maintaining parallel credential pipelines. Scalekit AgentKit eliminates that overhead. It stores one OAuth session per connector per user, handles token refresh automatically, and gives your agent a single API surface for 400+ connectors. This recipe shows how to discover AgentKit tools at runtime, wrap them as native Mastra tools, and run them through a Mastra agent — all in TypeScript, with no Python backend. ## What you are building - **A Mastra agent** that fetches Gmail messages through Scalekit AgentKit. - **Dynamic tool discovery** — the agent discovers available tools at runtime from Scalekit, instead of hardcoding tool definitions. - **Authorization link** — if the user has not connected their Gmail account, the agent generates an authorization URL. - **A pattern you can extend** to any of Scalekit's [400+ connectors](https://docs.scalekit.com/agentkit/connectors/) by changing a single string. The complete source is available in the [mastra-agentkit-example](https://github.com/scalekit-developers/mastra-agentkit-example) repository. ## Prerequisites - A Scalekit account at [app.scalekit.com](https://app.scalekit.com) with API credentials (**Settings → API Credentials**). - A **Gmail** connection configured under **AgentKit → Connections**. See [Configure a connection](https://docs.scalekit.com/agentkit/connections/). - An OpenAI API key. - **Node.js 18+** and **pnpm** (or npm). 1. ## Install dependencies ```bash title="Terminal" pnpm add @mastra/core @scalekit-sdk/node @ai-sdk/openai zod dotenv pnpm add -D tsx typescript @types/node ``` `@mastra/core` provides the `Agent` and `createTool` primitives. `@scalekit-sdk/node` handles authentication, tool discovery, and tool execution against the Scalekit API. `@ai-sdk/openai` connects the agent to GPT-4o. 2. ## Set environment variables Create a `.env` file at the project root: ```bash title=".env" # Scalekit — from app.scalekit.com → Settings → API Credentials # Threat: leaked credentials grant full API access to your Scalekit environment. # Never commit this file to version control; add .env to .gitignore. SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev SCALEKIT_CLIENT_ID=skc_your_client_id SCALEKIT_CLIENT_SECRET=your_client_secret # OpenAI # Threat: exposed key allows unauthorized model usage billed to your account. OPENAI_API_KEY=sk-your-openai-key # User and connection — replace with values from your application USER_IDENTIFIER=user_123 CONNECTION_NAME=gmail ``` | Variable | Purpose | |---|---| | `SCALEKIT_ENVIRONMENT_URL` | Your Scalekit environment URL (starts with `https://`) | | `SCALEKIT_CLIENT_ID` | Client ID from API Credentials (starts with `skc_`) | | `SCALEKIT_CLIENT_SECRET` | Client secret from API Credentials | | `OPENAI_API_KEY` | OpenAI API key for GPT-4o | | `USER_IDENTIFIER` | A unique identifier for the end user in your application | | `CONNECTION_NAME` | The connection name configured in your Scalekit dashboard | 3. ## Initialize Scalekit and ensure the user is connected Create `src/index.ts`. Start by initializing the Scalekit client and checking whether the user has an active Gmail connection: ```typescript title="src/index.ts" import { Agent } from '@mastra/core/agent'; import { createTool } from '@mastra/core/tools'; import { openai } from '@ai-sdk/openai'; import { ScalekitClient } from '@scalekit-sdk/node'; import { z } from 'zod'; import 'dotenv/config'; const IDENTIFIER = process.env.USER_IDENTIFIER || 'user_123'; const CONNECTION = process.env.CONNECTION_NAME || 'gmail'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ); const { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName: CONNECTION, identifier: IDENTIFIER, }); if (connectedAccount?.status?.toString() !== '1') { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName: CONNECTION, identifier: IDENTIFIER, }); console.log(`\n[${CONNECTION}] Authorization required.`); console.log(`Open this link:\n\n ${link}\n`); console.log('Press Enter once you have completed the OAuth flow...'); await new Promise((resolve) => { process.stdin.resume(); process.stdin.once('data', () => { process.stdin.pause(); resolve(); }); }); } ``` `getOrCreateConnectedAccount` returns an existing session if one exists or creates a pending one. If the account is not active (status `1`), `getAuthorizationLink` returns a URL you open in a browser. Scalekit handles the full OAuth exchange — your application never sees the provider's client secret. > tip: Production flow > > In a web application, you would redirect the user to the authorization link in the browser and handle the callback. The CLI approach here is for development and testing. 4. ## Discover tools from Scalekit Once the user is connected, list the tools available for their account: ```typescript title="src/index.ts (continued)" const toolsResponse = await scalekit.tools.listTools({ filter: { connector: CONNECTION, identifier: IDENTIFIER }, pageSize: 50, }); const scalekitTools = toolsResponse.tools; console.log( `Discovered ${scalekitTools.length} tools: ` + scalekitTools.map((t) => (t.definition as any)?.name).join(', ') ); ``` `listTools` returns tool definitions that include a `name`, `description`, and `input_schema` (a JSON Schema object). The `filter` parameter scopes results to the connector and user — the agent only sees tools the user has authorized. 5. ## Convert Scalekit tools to Mastra tools Mastra agents accept tools created with `createTool`. Each Scalekit tool needs to be wrapped: ```typescript title="src/index.ts (continued)" const mastraTools: Record> = {}; for (const tool of scalekitTools) { const def = tool.definition as Record | undefined; if (!def?.name) continue; const toolName: string = def.name; const description: string = def.description || toolName; // Use a permissive Zod schema — Scalekit validates inputs server-side. const inputSchema = z.object({}).passthrough(); mastraTools[toolName] = createTool({ id: toolName, description, inputSchema, execute: async ({ context }) => { const result = await scalekit.tools.executeTool({ toolName, connector: CONNECTION, identifier: IDENTIFIER, params: context as Record, }); return result; }, }); } ``` The `inputSchema` uses `z.object({}).passthrough()` — a permissive schema that lets the LLM pass any parameters through. Scalekit validates inputs server-side, so client-side validation is optional. If you want stricter types, convert the JSON Schema from `def.input_schema` into a typed Zod schema. The `execute` function calls `scalekit.tools.executeTool()`, which sends the tool call to Scalekit. Scalekit injects the user's OAuth token, calls the third-party API, and returns the structured response. 6. ## Build and run the agent Create the Mastra agent with the discovered tools and run it: ```typescript title="src/index.ts (continued)" const agent = new Agent({ name: 'gmail-assistant', instructions: 'You are a helpful Gmail assistant. Use the available tools to fulfill requests. ' + 'Always confirm what you did after completing an action.', model: openai('gpt-4o'), tools: mastraTools, }); const prompt = process.argv[2] || 'Fetch my last 5 unread emails and summarize them.'; console.log(`\nPrompt: ${prompt}\n`); const result = await agent.generate(prompt); console.log(result.text); ``` Set `"type": "module"` and add a start script in `package.json`. The code uses top-level `await`, which needs ES modules: ```json title="package.json (type and scripts)" { "type": "module", "scripts": { "start": "tsx src/index.ts" } } ``` 7. ## Run and verify ```bash title="Terminal" pnpm start ``` On the first run, if the user hasn't authorized Gmail, you see the authorization flow: ```text title="Terminal" [gmail] Authorization required. Open this link: https://auth.scalekit.dev/connect/... Press Enter once you have completed the OAuth flow... ``` After authorization (or on subsequent runs), the agent runs: ```text title="Terminal" Connected account for user_123 is active. Discovered 8 tools: gmail_fetch_mails, gmail_send_message, gmail_list_threads, ... Created 8 Mastra tools. Prompt: Fetch my last 5 unread emails and summarize them. Here are your 5 most recent unread emails: 1. "Q1 roadmap feedback needed" — Sarah Chen (1h ago) Requesting feedback on the product roadmap by Friday. 2. "Deploy failed: production" — GitHub Actions (2h ago) CI pipeline failed on the main branch, test suite timeout. 3. "New PR review requested" — Lin Feng (3h ago) Review requested on PR #412: refactor auth middleware. ... ``` You can also pass a custom prompt: ```bash title="Terminal" pnpm start "Search for emails from GitHub and list the subjects" ``` ## Common mistakes **Connected account stays in PENDING_AUTH** You did not complete the OAuth flow in the browser. AgentKit waits for you to authorize through the URL returned by `getAuthorizationLink`. **Solution:** Open the printed URL in a browser, complete the Google OAuth consent, and return to the terminal. The connected account status updates to `ACTIVE` after a successful callback. **Tool list is empty** The connection name in code does not match the connection name in the Scalekit dashboard, or the connected account is not active. **Solution:** Open **AgentKit → Connections** in the dashboard. Verify the connection name matches exactly (case-sensitive). Then check that the connected account for your identifier shows **ACTIVE** status. **executeTool fails with identifier error** The `identifier` you passed to `executeTool` does not match the identifier you used when creating the connected account. **Solution:** Use the same `identifier` value throughout — `getOrCreateConnectedAccount`, `listTools`, and `executeTool` must all receive the same string. **Agent generates text instead of calling tools** The model did not receive tool definitions with enough detail to trigger a tool call. This happens when every tool has an empty description or when the `inputSchema` is missing entirely. **Solution:** Verify that `scalekitTools` is not empty after discovery. Print `Object.keys(mastraTools)` to confirm tools were created. If tools exist but the model still does not call them, check that the tool descriptions are informative — the LLM uses descriptions to decide when a tool is relevant. ## Production notes **Token refresh is automatic.** Scalekit stores OAuth tokens per user per connector and refreshes them before expiry. Your agent code never handles refresh tokens directly. **Scope tools per user.** The `identifier` parameter in `listTools` and `executeTool` ensures each user only accesses their own connected accounts. Never share an identifier across users. **Add more connectors.** Change `CONNECTION_NAME` to `slack`, `notion`, `googlecalendar`, or any of the [400+ supported connectors](https://docs.scalekit.com/agentkit/connectors/). The code is identical — only the connection name changes. **Error handling in production.** Wrap `executeTool` calls in try/catch to handle network errors and expired connections gracefully. Return a clear message to the user when a tool call fails instead of letting the agent retry silently. **MCP alternative.** If you prefer Mastra's built-in MCP client over manual tool wrapping, see the [Mastra MCP example](https://docs.scalekit.com/agentkit/examples/mastra/). That approach requires a per-user MCP URL generated from the Python SDK. ## Next steps - [Configure more connectors](https://docs.scalekit.com/agentkit/connectors/) — add Slack, GitHub, Salesforce, and others alongside Gmail. - [Mastra MCP integration](https://docs.scalekit.com/agentkit/examples/mastra/) — use Mastra's native MCP client with a Scalekit MCP URL. - [AgentKit quickstart](https://docs.scalekit.com/agentkit/quickstart/) — connect your first user in under five minutes. - [Connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/) — manage user connections, check status, and revoke access programmatically. ## Related resources | Topic | Link | |---|---| | AgentKit overview | [Overview](https://docs.scalekit.com/agentkit/overview/) | | All connectors | [Connectors](https://docs.scalekit.com/agentkit/connectors/) | | Connected accounts | [Manage connected accounts](https://docs.scalekit.com/agentkit/connected-accounts/) | | Mastra MCP example | [Mastra](https://docs.scalekit.com/agentkit/examples/mastra/) | | Sample repository | [mastra-agentkit-example](https://github.com/scalekit-developers/mastra-agentkit-example) | | Mastra docs | [mastra.ai/docs](https://mastra.ai/docs) | ## API reference The endpoints this page's code calls, with every field and the SDK method for each: - `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md) - `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md) - `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md) - `GET` [List tools](https://docs.scalekit.com/agentkit/reference/tools/list-tools.md) --- Source: https://docs.scalekit.com/cookbooks/render-github-pr-summarizer.md # Build a multi-user GitHub PR summarizer agent Build a GitHub PR summarizer that binds each connected GitHub account to a secure browser session instead of trusting a client-supplied user ID. This recipe builds a GitHub PR summarizer with a browser UI and a secure connected-account flow. Each user connects GitHub once, then the app reuses that connected token for later PR summary requests in the same browser session. The important security rule is straightforward: **never accept a user ID from the browser and use it as the Scalekit connected-account identifier**. Instead, mint an opaque identifier on the server, store it in your own session store, and complete the flow with [user verification for connected accounts](https://docs.scalekit.com/agentkit/user-verification/). The finished app does four things: - lists the most-discussed open pull requests in a repository - fetches each PR's diff and comment thread through Scalekit's GitHub connector - asks an LLM to summarize the PRs in plain language - binds every GitHub connection to a secure browser session instead of a client-supplied identifier The complete source is available in the [render-ai-agent-deploykit](https://github.com/scalekit-developers/render-ai-agent-deploykit) repository. You can also [watch the video walkthrough](https://youtu.be/w3atzSkKE1w) to see the full setup and demo end-to-end. > note: Why this cookbook stays TypeScript-only > > This sample uses Render's Node SDK and ships as a TypeScript project, so the cookbook mirrors the repo and stays TypeScript-only. For multi-language examples of the verification flow itself, see [user verification for connected accounts](https://docs.scalekit.com/agentkit/user-verification/). ## What you are building The app runs as a Node web service on Render. It serves an HTML page with a **Connect GitHub** button and a form for `owner` and `repo`. Under the hood, the flow looks like this: ```text Browser (original tab) Browser (new tab) │ │ ▼ GET / │ Express server sets signed session cookie │ │ │ ▼ POST /api/auth │ Scalekit returns GitHub auth link │ │ │ │ opens auth link ─────────────────► ▼ │ GitHub OAuth consent │ │ │ polls GET /api/auth/status ▼ │ ◄─── Scalekit API: ACTIVE ──► Scalekit verifies account │ ▼ page auto-reloads │ ▼ POST /api/summarize { repository } Scalekit runs GitHub requests with the connected user's token ``` The OAuth flow opens in a **new tab** so the app page stays intact. The original tab polls the Scalekit API until the connected account becomes `ACTIVE`, then auto-reloads to show the connected state. ## 1. Set up the GitHub connector Create the connector once per Scalekit environment. 1. Go to [app.scalekit.com](https://app.scalekit.com) → **AgentKit** > **Connections** > **Create Connection** 2. Find **GitHub** and click **Create** 3. Follow the setup — Scalekit creates and manages the GitHub OAuth app for you 4. Note the **connection name** assigned (e.g. `github-qkHFhMip`) — you'll set this as `GITHUB_CONNECTION_NAME` in your environment > caution: Connection names are unique per environment > > Scalekit generates a unique GitHub connection name for each environment. Do not copy one from a tutorial or another project. Always use the exact value from your own Scalekit Dashboard. ## 2. Configure user verification (required) Scalekit's user verification setting controls what happens after a user completes GitHub OAuth. Set it in **AgentKit > Settings > User Verification** in the [Scalekit dashboard](https://app.scalekit.com). | Mode | When to use | What happens after OAuth | |------|-------------|--------------------------| | **None** (the default for new environments) | Trying the app | The connected account goes `ACTIVE` as soon as OAuth completes, with no identity check. | | **Scalekit users only** | Development and testing | Scalekit verifies the user internally. The connected account goes `ACTIVE` automatically. The app detects this by polling the Scalekit API. | | **Custom user verifier** | Production | Scalekit redirects the browser to your app's `/user/verify` callback. The server calls `verifyConnectedAccountUser` to activate the account. The app also polls the Scalekit API as a fallback. | The app works in every mode without code changes. Switch to **Custom user verifier** before you deploy for real users. > caution: Stuck on waiting for authorization? > > If you complete GitHub OAuth but the app never shows "GitHub connected," check this setting first. With **Custom user verifier**, the account stays pending until your `/user/verify` route runs. The app builds that URL from the request's origin, so open the app on its deployed URL, not a local one. For the full verification model, see [user verification for connected accounts](https://docs.scalekit.com/agentkit/user-verification/). ## 3. Create the project ```bash title="Terminal" mkdir render-pr-summarizer && cd render-pr-summarizer npm init -y npm install @renderinc/sdk@0 @scalekit-sdk/node openai dotenv express npm install -D typescript tsx @types/node @types/express ``` ```json title="package.json" { "type": "module", "scripts": { "dev": "tsx src/main.ts", "build": "tsc", "start": "node dist/main.js" } } ``` ```json title="tsconfig.json" { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "src", "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"] } ``` ## 4. Configure environment variables ```bash title="Terminal" cp .env.example .env ``` ```bash title=".env" PORT=3000 SESSION_SECRET=replace-with-openssl-rand-hex-32 OPENAI_API_KEY=your-api-key OPENAI_MODEL=gpt-4.1-mini # Leave OPENAI_BASE_URL empty for OpenAI direct. # Set it to a proxy URL for LiteLLM, Azure OpenAI, Ollama, etc. # OPENAI_BASE_URL=https://your-litellm-proxy.example.com SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com SCALEKIT_CLIENT_ID=your-scalekit-client-id SCALEKIT_CLIENT_SECRET=your-scalekit-client-secret GITHUB_CONNECTION_NAME=your-github-connection-name # Optional — the app auto-detects its public URL from proxy headers. # Only set this if you need to pin the callback origin explicitly. # PUBLIC_BASE_URL=http://localhost:3000 ``` Generate `SESSION_SECRET` with: ```bash title="Terminal" openssl rand -hex 32 ``` > note: Any OpenAI-compatible API works > > The sample uses the `openai` npm package with a configurable `baseURL`. Set `OPENAI_BASE_URL` to route calls through LiteLLM, Azure OpenAI, Ollama, or any other OpenAI-compatible endpoint. The API key must match the endpoint it is sent to. > note: PUBLIC_BASE_URL is optional > > The app infers its public URL from Render's `x-forwarded-proto` and `host` proxy headers automatically. You only need to set `PUBLIC_BASE_URL` if you are behind a custom domain or an unusual reverse proxy. On first deploy to Render, you can leave it unset — the app works without it. ## 5. Add Scalekit auth helpers The helper layer creates connected accounts, generates auth links, verifies the callback, and routes GitHub API calls through Scalekit's connector. ```typescript title="src/scalekit.ts" import "dotenv/config"; import { ConnectorStatus, ScalekitClient } from "@scalekit-sdk/node"; import type { JsonObject } from "@bufbuild/protobuf"; let _scalekit: ScalekitClient | null = null; function getScalekit(): ScalekitClient { if (_scalekit) return _scalekit; if (!process.env.SCALEKIT_ENVIRONMENT_URL || !process.env.SCALEKIT_CLIENT_ID || !process.env.SCALEKIT_CLIENT_SECRET) { throw new Error("Missing SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, or SCALEKIT_CLIENT_SECRET"); } _scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL, process.env.SCALEKIT_CLIENT_ID, process.env.SCALEKIT_CLIENT_SECRET, ); return _scalekit; } export const scalekit = new Proxy({} as ScalekitClient, { get(_target, prop) { return (getScalekit() as unknown as Record)[prop]; }, }); const githubConnectionName = process.env.GITHUB_CONNECTION_NAME; if (!githubConnectionName) { throw new Error( "GITHUB_CONNECTION_NAME is required. Copy the connection name from Scalekit Dashboard > AgentKit > Connections.", ); } // Typed as string, so the functions below can use it under strict mode const GITHUB_CONNECTION_NAME: string = githubConnectionName; export async function getGitHubAuthLink( identifier: string, opts: { state: string; userVerifyUrl: string }, ): Promise { await scalekit.actions.getOrCreateConnectedAccount({ connectionName: GITHUB_CONNECTION_NAME, identifier, }); const res = await scalekit.actions.getAuthorizationLink({ connectionName: GITHUB_CONNECTION_NAME, identifier, state: opts.state, userVerifyUrl: opts.userVerifyUrl, }); if (!res.link) { throw new Error( `Scalekit did not return a GitHub authorization link for '${GITHUB_CONNECTION_NAME}' and identifier '${identifier}'`, ); } return res.link; } export async function verifyUser(params: { authRequestId: string; identifier: string; }): Promise { await scalekit.actions.verifyConnectedAccountUser({ authRequestId: params.authRequestId, identifier: params.identifier, }); } /** * Check the connected account status via Scalekit API. * Returns true when the account is active (OAuth complete and verified). */ export async function isAccountActive(identifier: string): Promise { try { const res = await scalekit.actions.getConnectedAccount({ connectionName: GITHUB_CONNECTION_NAME, identifier, }); return res.connectedAccount?.status === ConnectorStatus.ACTIVE; } catch { return false; } } export async function githubTool( identifier: string, toolName: string, toolInput: Record, ): Promise { const res = await scalekit.actions.executeTool({ toolName, toolInput, connector: GITHUB_CONNECTION_NAME, identifier, }); return res.data ?? {}; } export async function githubRequest( identifier: string, path: string, options: { method?: string; headers?: Record; queryParams?: Record; } = {}, ) { const res = await scalekit.actions.request({ connectionName: GITHUB_CONNECTION_NAME, identifier, path, method: options.method ?? "GET", headers: options.headers, queryParams: options.queryParams, }); return res.data; } ``` > caution: Use the exact connector name > > The `connector` value in `executeTool` must be the full connection name from your own Scalekit environment, not the generic provider string `"github"`. ## 6. Bind the browser session to an opaque identifier The session layer is the security boundary for the whole app. Create `src/session.ts` and store three things: - a signed session cookie sent to the browser - an opaque `usr_...` identifier stored on the server - a one-time `state` value stored on the server while OAuth is in flight ```typescript title="src/session.ts" import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import type { Request, Response } from "express"; const COOKIE_NAME = "sid"; const STATE_TTL_MS = 10 * 60 * 1000; interface SessionEntry { identifier: string; pendingState?: string; pendingStateExpiresAt?: number; connectedAt?: number; } const store = new Map(); function getSecret(): string { const secret = process.env.SESSION_SECRET; if (!secret) { throw new Error("SESSION_SECRET is required"); } return secret; } function sign(sessionId: string): string { const mac = createHmac("sha256", getSecret()).update(sessionId).digest("base64url"); return `${sessionId}.${mac}`; } function unsign(signed: string): string | null { const dot = signed.lastIndexOf("."); if (dot < 0) return null; const sessionId = signed.slice(0, dot); const mac = signed.slice(dot + 1); const expected = createHmac("sha256", getSecret()).update(sessionId).digest("base64url"); const expectedBuf = Buffer.from(expected); const macBuf = Buffer.from(mac); if (expectedBuf.length !== macBuf.length) return null; return timingSafeEqual(expectedBuf, macBuf) ? sessionId : null; } export function requireSession(req: Request, res: Response) { const cookies = Object.fromEntries( (req.headers.cookie ?? "") .split(";") .flatMap((pair) => { const eq = pair.indexOf("="); if (eq < 0) return []; try { return [[pair.slice(0, eq).trim(), decodeURIComponent(pair.slice(eq + 1).trim())]]; } catch { return []; } }), ); const raw = cookies[COOKIE_NAME]; let sessionId = raw ? unsign(raw) : null; let entry = sessionId ? store.get(sessionId) ?? null : null; if (!sessionId || !entry) { sessionId = randomBytes(32).toString("base64url"); entry = { identifier: "" }; store.set(sessionId, entry); } // The cookie only carries a random opaque session id. HMAC signing is enough // to detect tampering because the sensitive identifier stays server-side. const protoHeader = req.get("x-forwarded-proto"); const requestIsSecure = req.secure || protoHeader?.split(",")[0]?.trim() === "https"; const secure = process.env.NODE_ENV === "production" || process.env.PUBLIC_BASE_URL?.startsWith("https://") === true || requestIsSecure; const parts = [ `${COOKIE_NAME}=${sign(sessionId)}`, "HttpOnly", "SameSite=Lax", "Path=/", `Max-Age=${7 * 24 * 60 * 60}`, ]; if (secure) parts.push("Secure"); res.setHeader("Set-Cookie", parts.join("; ")); return { entry }; } export function mintIdentifier(entry: SessionEntry): string { if (!entry.identifier) { entry.identifier = `usr_${randomBytes(16).toString("hex")}`; } return entry.identifier; } export function setPendingState(entry: SessionEntry, state: string): void { entry.pendingState = state; entry.pendingStateExpiresAt = Date.now() + STATE_TTL_MS; } export function consumePendingState(entry: SessionEntry, incoming: string): boolean { const stored = entry.pendingState; const expiresAt = entry.pendingStateExpiresAt; entry.pendingState = undefined; entry.pendingStateExpiresAt = undefined; if (!stored || !expiresAt || Date.now() > expiresAt) return false; const storedBuf = Buffer.from(stored); const incomingBuf = Buffer.from(incoming); if (storedBuf.length !== incomingBuf.length) return false; return timingSafeEqual(storedBuf, incomingBuf); } export function markConnected(entry: SessionEntry): void { entry.connectedAt = Date.now(); } export function isConnected(entry: SessionEntry): boolean { return entry.connectedAt !== undefined; } ``` > caution: Never trust query params for identity > > Read the identifier from your own session store, not from the URL and not from the request body. The callback query string only proves that Scalekit completed an OAuth flow. Your server must decide which local user session owns that new connection. ## 7. Add the tasks The task layer now accepts a server-side `identifier`, not a browser-supplied `userId`. ```typescript title="src/tasks.ts" import { task } from "@renderinc/sdk/workflows"; import OpenAI from "openai"; import { githubRequest, githubTool, getGitHubAuthLink } from "./scalekit.js"; export interface PRSummaryInput { identifier: string; owner: string; repo: string; } const fetchOpenPRs = task( { name: "fetchOpenPRs", retry: { maxRetries: 3, waitDurationMs: 1000 } }, async function fetchOpenPRs(identifier: string, owner: string, repo: string) { const raw = await githubTool(identifier, "github_pull_requests_list", { owner, repo, state: "open", }); const r = raw as Record; const list = Array.isArray(raw) ? raw : Array.isArray(r.array) ? r.array : Array.isArray(r.pull_requests) ? r.pull_requests : Array.isArray(r.data) ? r.data : null; if (!list) { throw new Error(`Unexpected response shape: ${JSON.stringify(raw).slice(0, 200)}`); } type PRItem = { number: number; title: string; comments: number; review_comments: number }; return (list as PRItem[]) .sort((a, b) => (b.comments + b.review_comments) - (a.comments + a.review_comments)) .slice(0, 5); }, ); const fetchPRDetails = task( { name: "fetchPRDetails", retry: { maxRetries: 3, waitDurationMs: 1000 } }, async function fetchPRDetails(identifier: string, owner: string, repo: string, prNumber: number) { const [diffRaw, commentsRaw] = await Promise.all([ githubRequest(identifier, `/repos/${owner}/${repo}/pulls/${prNumber}`, { headers: { Accept: "application/vnd.github.diff" }, }), githubRequest(identifier, `/repos/${owner}/${repo}/issues/${prNumber}/comments`), ]); const diff = typeof diffRaw === "string" ? diffRaw.slice(0, 3000) : ""; const comments = Array.isArray(commentsRaw) ? commentsRaw : []; return { diff, comments }; }, ); export const setupGitHubAuthTask = task( { name: "setupGitHubAuth" }, async function setupGitHubAuth(params: { identifier: string; state: string; userVerifyUrl: string; }) { const link = await getGitHubAuthLink(params.identifier, { state: params.state, userVerifyUrl: params.userVerifyUrl, }); return { authLink: link }; }, ); // ---- LLM summary ---- function createOpenAIClient(): OpenAI { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) throw new Error("OPENAI_API_KEY not set"); return new OpenAI({ apiKey, ...(process.env.OPENAI_BASE_URL && { baseURL: process.env.OPENAI_BASE_URL }), }); } const generateSummary = task( { name: "generateSummary", retry: { maxRetries: 3, waitDurationMs: 2000 } }, async function generateSummary( prs: { number: number; title: string; diff: string; comments: { body?: string }[] }[], owner: string, repo: string, ): Promise { if (prs.length === 0) return "No open pull requests found in this repository."; const client = createOpenAIClient(); const prBlocks = prs .map((pr) => { const bodies = pr.comments.slice(0, 5).map((c) => `> ${(c.body ?? "").slice(0, 300)}`).join("\n"); return `PR #${pr.number} — ${pr.title}\n${bodies || "No comments."}\nDiff:\n${pr.diff || "(not available)"}`; }) .join("\n\n---\n\n"); const response = await client.chat.completions.create({ model: process.env.OPENAI_MODEL ?? "gpt-4.1-mini", messages: [ { role: "system", content: "Summarize each PR in one paragraph (3-4 sentences) for a team lead. " + "Cover what it does, how much discussion happened, and whether it looks close to merging.", }, { role: "user", content: `Repository: ${owner}/${repo}\n\n${prBlocks}` }, ], }); return response.choices[0].message.content ?? "(no summary generated)"; }, ); // ---- Root task ---- export const summarizePRsTask = task( { name: "summarizePRs", timeoutSeconds: 120 }, async function summarizePRs(input: PRSummaryInput) { const { identifier, owner, repo } = input; const topPRs = await fetchOpenPRs(identifier, owner, repo); if (topPRs.length === 0) { return { repository: `${owner}/${repo}`, prsAnalyzed: [] as string[], summary: "No open pull requests found." }; } const details = await Promise.all( topPRs.map((pr) => fetchPRDetails(identifier, owner, repo, pr.number)), ); const prsForSummary = topPRs.map((pr, i) => ({ number: pr.number, title: pr.title, diff: details[i].diff, comments: details[i].comments as { body?: string }[], })); const summary = await generateSummary(prsForSummary, owner, repo); return { repository: `${owner}/${repo}`, prsAnalyzed: topPRs.map((p) => `#${p.number}: ${p.title}`), summary, }; }, ); ``` ## 8. Wire the HTTP server The HTTP server owns the secure flow. It issues the session cookie, starts the GitHub auth flow, validates the callback, and blocks summary requests until the session is connected. ```typescript title="src/server.ts" import crypto from "node:crypto"; import express from "express"; import { setupGitHubAuthTask, summarizePRsTask } from "./tasks.js"; import { isAccountActive, verifyUser } from "./scalekit.js"; import { consumePendingState, isConnected, markConnected, mintIdentifier, requireSession, setPendingState, } from "./session.js"; import { renderHomePage, renderAuthCompletePage } from "./views.js"; import type { Request } from "express"; function getConfiguredPublicBaseUrl(): string | null { const value = process.env.PUBLIC_BASE_URL; return value ? value.replace(/\/$/, "") : null; } function getRequestOrigin(req: Request): string { const configured = getConfiguredPublicBaseUrl(); if (configured) return configured; const protoHeader = req.get("x-forwarded-proto"); const proto = protoHeader?.split(",")[0]?.trim() || req.protocol || "http"; const host = req.get("x-forwarded-host") || req.get("host"); if (!host) { throw new Error("Could not determine the public origin for this request"); } return `${proto}://${host}`; } export function startServer(): void { const app = express(); app.set("trust proxy", true); app.use(express.json()); app.get("/", (req, res) => { const { entry } = requireSession(req, res); res.type("html").send(renderHomePage({ connected: isConnected(entry) })); }); // Polled by the original tab while the OAuth tab is open. // Checks the in-memory session first, then queries the Scalekit API // to detect when the connected account becomes ACTIVE. app.get("/api/auth/status", async (req, res) => { const { entry } = requireSession(req, res); if (isConnected(entry)) { res.json({ connected: true }); return; } if (entry.identifier && await isAccountActive(entry.identifier)) { markConnected(entry); res.json({ connected: true }); return; } res.json({ connected: false }); }); app.post("/api/auth", async (req, res) => { const { entry } = requireSession(req, res); const identifier = mintIdentifier(entry); const state = crypto.randomUUID(); setPendingState(entry, state); const result = await setupGitHubAuthTask({ identifier, state, userVerifyUrl: `${getRequestOrigin(req)}/user/verify`, }); res.json({ authLink: result.authLink }); }); // Callback for custom user verification mode. When Scalekit is // configured in "Scalekit users only" mode, this route may not fire — // the /api/auth/status polling handles that case via the Scalekit API. app.get("/user/verify", async (req, res) => { const { auth_request_id, state } = req.query as Record; if (!auth_request_id || !state) { res.status(400).send("Missing auth_request_id or state"); return; } const { entry } = requireSession(req, res); if (!entry.identifier) { res.status(400).send("No pending authorization for this session"); return; } if (!consumePendingState(entry, state)) { res.status(400).send("Invalid or expired state"); return; } await verifyUser({ authRequestId: auth_request_id, identifier: entry.identifier, }); markConnected(entry); // This handler runs in the OAuth tab. Render a minimal page // telling the user to close it — the original tab is polling // /api/auth/status and will auto-reload. res.type("html").send(renderAuthCompletePage()); }); app.post("/api/summarize", async (req, res) => { const { entry } = requireSession(req, res); if (!isConnected(entry)) { res.status(401).json({ error: "Connect your GitHub account first" }); return; } // The UI sends { repository: "https://github.com/owner/repo" } or "owner/repo". // Parse the string into separate owner and repo values. const { repository } = req.body as { repository?: string }; if (!repository) { res.status(400).json({ error: "Provide a GitHub repository URL or owner/repo name." }); return; } let owner: string | undefined; let repo: string | undefined; try { const url = new URL(repository); const segments = url.pathname.split("/").filter(Boolean); owner = segments[0]; repo = segments[1]?.replace(/\.git$/, ""); } catch { const parts = repository.split("/"); owner = parts[0]; repo = parts[1]?.replace(/\.git$/, ""); } if (!owner || !repo) { res.status(400).json({ error: "Provide a GitHub repository URL or owner/repo name." }); return; } const result = await summarizePRsTask({ identifier: entry.identifier, owner, repo }); res.json(result); }); // Render sets PORT; locally the app listens on 3000 const port = Number(process.env.PORT ?? 3000); app.listen(port, () => { console.log(`Listening on http://localhost:${port}`); }); } ``` Then add the entry point that `npm run dev` and `npm start` run: ```typescript title="src/main.ts" import { startServer } from "./server.js"; startServer(); ``` ## 9. Render the browser UI The UI only asks for a repository. It does not ask for a user identifier. After a successful connection, the page auto-reloads and shows a connected banner. The key change from a naive implementation: `connectGitHub()` opens the auth link in a **new tab** instead of navigating the current page. This keeps the app intact even if the OAuth redirect chain doesn't return cleanly. The original tab polls `/api/auth/status` and auto-reloads when the Scalekit API reports the account as `ACTIVE`. ```typescript title="src/views.ts" export function renderAuthCompletePage(): string { return `

✓ GitHub connected

You can close this tab and return to the app. The original page will update automatically.

`; } export function renderHomePage({ connected }: { connected: boolean }): string { const connectedBanner = connected ? `
✓ GitHub connected
` : `
Connect GitHub before summarizing pull requests.
`; const authButtonLabel = connected ? "Reconnect GitHub" : "Connect GitHub"; return ` ${connectedBanner}

      
    
  `;
}
```

## 10. Run locally

1. Copy `.env.example` to `.env` and fill in your values.
2. Run `npm install`.
3. Run `npm run dev`.
4. Open `http://localhost:3000`.
5. Click **Connect GitHub**. A new tab opens for the GitHub OAuth flow.
6. Complete the OAuth consent in the new tab.
7. The new tab shows "GitHub connected — you can close this tab" (in custom verification mode) or a Scalekit success page (in Scalekit-users-only mode).
8. The original tab auto-detects the connection and reloads, showing a **GitHub connected** banner.
9. Enter a repository URL or `owner/repo`, then generate a summary.

Public repositories work with any connected GitHub account. Private repositories only work if the connected account has access.

## 11. Deploy to Render

Render deploys the app as a web service from `render.yaml`.

Set these environment variables in Render:

| Variable | Required | Notes |
|----------|----------|-------|
| `SCALEKIT_ENVIRONMENT_URL` | Yes | From Scalekit dashboard → Developers → API Credentials |
| `SCALEKIT_CLIENT_ID` | Yes | Same location |
| `SCALEKIT_CLIENT_SECRET` | Yes | Same location |
| `GITHUB_CONNECTION_NAME` | Yes | From AgentKit → Connectors |
| `OPENAI_API_KEY` | Yes | OpenAI key or proxy token |
| `OPENAI_BASE_URL` | No | Leave empty for OpenAI direct. Set for LiteLLM/Azure/Ollama. |
| `OPENAI_MODEL` | No | Default: `gpt-4.1-mini` |
| `SESSION_SECRET` | Auto | `render.yaml` auto-generates this |
| `PUBLIC_BASE_URL` | No | Auto-detected from proxy headers. Only needed behind a custom domain. |

After deploying, configure user verification in the Scalekit dashboard ([step 2](#2-configure-user-verification-required)). The app will not complete the GitHub connection flow without this.

## Production notes

- **User verification mode**: Switch to **Custom user verifier** in the Scalekit dashboard before going to production. This ensures your backend confirms which session owns each new connection.
- **Shared session store**: The sample stores session data in memory. Use Redis or a database-backed shared store in production.
- **Short-lived OAuth state**: The sample expires the pending `state` after 10 minutes and consumes it after a single callback.
- **Session-bound identifier**: The browser never chooses the identifier that Scalekit uses to look up the connected account.
- **Connector-backed GitHub requests**: The sample routes both PR listing and PR detail fetches through Scalekit so the connected user's token is used consistently.

## Next steps

- Read [user verification for connected accounts](https://docs.scalekit.com/agentkit/user-verification/) for the full verification model and additional examples.
- Read [authorize a user](https://docs.scalekit.com/agentkit/tools/authorize/) for the status-polling pattern used to detect when a connected account becomes `ACTIVE`.
- Open the [render-ai-agent-deploykit](https://github.com/scalekit-developers/render-ai-agent-deploykit) repository to compare the full implementation against the snippets in this cookbook.

## API reference

The endpoints this page's code calls, with every field and the SDK method for each:

- `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md)
- `GET` [Get a connected account's credentials](https://docs.scalekit.com/agentkit/reference/connected-accounts/get-connected-account-credentials.md)
- `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md)
- `POST` [Verify the user](https://docs.scalekit.com/agentkit/reference/authorization/verify-the-user.md)
- `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md)

---

Source: https://docs.scalekit.com/cookbooks/schedule-meeting-and-draft-email.md

# Build an agent that books meetings and drafts emails

Connect a Python agent to Google Calendar and Gmail via Scalekit to find free slots, book meetings, and draft follow-up emails.
Scheduling a meeting sounds simple: find a free slot, create an event, send a confirmation. But in an agent, each of those steps crosses a tool boundary — and each tool requires its own OAuth token. Without a managed auth layer, you end up writing token-fetching, refresh logic, and error handling three times over before you write a single line of scheduling logic. This cookbook solves that by using Scalekit to own the OAuth lifecycle for each connector, so your agent can focus on the workflow itself.

This is a Python recipe for agents that call two or more external APIs on behalf of a user. The code on this page is the complete script: save the steps below in one file, `meeting_scheduler_agent.py`.

**The core problems this solves:**

- **One token per connector** — Google Calendar and Gmail use separate OAuth scopes and separate access tokens. Your agent must manage both independently.
- **First-run authorization is blocking** — If the user has not yet authorized a connector, your agent cannot proceed until they complete the browser OAuth flow.
- **Token expiry is silent** — A token that worked yesterday fails today, and the failure looks identical to a permissions error.
- **Chaining tool outputs is fragile** — The event link from the Calendar API needs to appear in the Gmail draft. If the Calendar call fails mid-workflow, the draft gets a broken link or never gets created.

Scalekit exposes a `connected_accounts` abstraction that maps a user ID to an authorized OAuth session per connector. When your agent calls `get_or_create_connected_account`, Scalekit returns the user's account for that connector. If it isn't `ACTIVE` yet, `get_authorization_link` gives you a URL for the user to authorize. From then on, Scalekit stores the tokens and refreshes them automatically.

Your agent never handles a token. It calls `actions.execute_tool` with a tool name, such as `googlecalendar_query_freebusy`, and Scalekit makes the Google API call with the right user's credentials. The authorization step is one function for every connector, and each API call is one `execute_tool` call.

1. **Set up the environment**

   Create a `.env` file at the project root with your Scalekit credentials:

   ```bash
   SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com
   SCALEKIT_CLIENT_ID=your-client-id
   SCALEKIT_CLIENT_SECRET=your-client-secret
   ```

   Install dependencies:

   ```bash
   pip install scalekit-sdk-python python-dotenv
   ```

   In the Scalekit Dashboard, create two connections for your environment:

   - `googlecalendar` — Google Calendar OAuth connection
   - `gmail` — Gmail OAuth connection

   The script references these names literally. The names must match exactly.

2. **Initialize the Scalekit client**

   ```python
   # meeting_scheduler_agent.py
   import os
   from datetime import datetime, timezone, timedelta

   from dotenv import load_dotenv
   from scalekit import ScalekitClient

   load_dotenv()

   # Never hard-code credentials — they would be exposed in source control
   # and CI logs. Pull them from environment variables instead.
   scalekit_client = ScalekitClient(
       env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"),
       client_id=os.getenv("SCALEKIT_CLIENT_ID"),
       client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
   )

   actions = scalekit_client.actions

   # Replace with a real user identifier from your application's session
   USER_ID = "user_123"
   ATTENDEE_EMAIL = "attendee@example.com"
   MEETING_TITLE = "Quick Sync"
   DURATION_MINUTES = 60
   SEARCH_DAYS = 3
   WORK_START_HOUR = 9   # UTC
   WORK_END_HOUR = 17    # UTC
   ```

   `scalekit_client.actions` is the entry point for all connected-account operations. Initialize it once and pass `actions` to the functions below.

3. **Authorize each connector**

   The `authorize` function makes sure the user has an active connected account for a connector. On first run, it prints an authorization link and waits for the user to finish the browser OAuth flow:

   ```python
   def authorize(connector: str) -> None:
       """Ensure the user has an ACTIVE connected account for this connector."""
       response = actions.get_or_create_connected_account(
           connection_name=connector,
           identifier=USER_ID,
       )

       if response.connected_account.status != "ACTIVE":
           link_response = actions.get_authorization_link(
               connection_name=connector,
               identifier=USER_ID,
           )
           print(f"\nOpen this link to authorize {connector}:\n{link_response.link}\n")
           input("Press Enter after completing authorization in your browser…")

           response = actions.get_connected_account(
               connection_name=connector,
               identifier=USER_ID,
           )
           if response.connected_account.status != "ACTIVE":
               raise RuntimeError(f"{connector} is still not authorized")
   ```

   Call it once per connector before any tool calls:

   ```python
   authorize("googlecalendar")
   authorize("gmail")
   ```

   After the first successful authorization, the account is already `ACTIVE` on later runs and the `if` block is skipped. Scalekit refreshes expired tokens automatically.

4. **Query calendar availability**

   Call `googlecalendar_query_freebusy` to get the user's busy intervals:

   ```python
   def get_busy_slots() -> list[dict]:
       """Fetch busy intervals for the user's primary calendar."""
       now = datetime.now(timezone.utc)
       window_end = now + timedelta(days=SEARCH_DAYS)

       result = actions.execute_tool(
           tool_name="googlecalendar_query_freebusy",
           connection_name="googlecalendar",
           identifier=USER_ID,
           tool_input={
               "calendar_ids": ["primary"],
               "time_min": now.isoformat(),
               "time_max": window_end.isoformat(),
           },
       )
       return result.data["calendars"]["primary"]["busy"]
   ```

   The tool returns Google's free/busy response as `result.data`. If the call fails, `execute_tool` raises an exception with the error from Google, so the caller never gets a silently wrong result. The `busy` list contains `{"start": "...", "end": "..."}` dicts with ISO 8601 timestamps.

5. **Find the first open slot**

   Walk forward in one-hour increments from now and return the first candidate that falls within working hours and does not overlap a busy interval:

   ```python
   def parse_time(value: str) -> datetime:
       # Google returns UTC times with a trailing Z, which fromisoformat
       # accepts only from Python 3.11
       return datetime.fromisoformat(value.replace("Z", "+00:00"))


   def find_free_slot(busy_slots: list[dict]) -> tuple[datetime, datetime] | None:
       """Return the first open one-hour slot during working hours in UTC.

       Returns None if no slot is available in the search window.
       """
       now = datetime.now(timezone.utc)
       # Round up to the next whole hour so the candidate is always in the future
       candidate = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
       window_end = now + timedelta(days=SEARCH_DAYS)

       while candidate < window_end:
           slot_end = candidate + timedelta(minutes=DURATION_MINUTES)

           if WORK_START_HOUR <= candidate.hour < WORK_END_HOUR:
               overlap = any(
                   candidate < parse_time(b["end"]) and slot_end > parse_time(b["start"])
                   for b in busy_slots
               )
               if not overlap:
                   return candidate, slot_end

           candidate += timedelta(hours=1)

       return None
   ```

   This is a useful first-draft strategy: simple, readable, easy to debug. Its limits are real (one-hour granularity, UTC-only, primary calendar only) and addressed in [Production notes](#production-notes) below.

6. **Create the calendar event**

   Call `googlecalendar_create_event` and return the event's link, which you'll include in the email draft:

   ```python
   def create_event(start: datetime) -> str:
       """Create a calendar event and return its link."""
       result = actions.execute_tool(
           tool_name="googlecalendar_create_event",
           connection_name="googlecalendar",
           identifier=USER_ID,
           tool_input={
               "summary": MEETING_TITLE,
               "description": "Scheduled by agent",
               "start_datetime": start.isoformat(),
               "event_duration_minutes": DURATION_MINUTES,
               "timezone": "UTC",
               "attendees_emails": [ATTENDEE_EMAIL],
           },
       )
       return result.data["event"]["htmlLink"]
   ```

   The tool returns the created event under `event`, and its `htmlLink` is the calendar event URL. Google also sends an invitation email to each attendee automatically when the event is created; the draft you create in the next step is a separate follow-up, not the invitation itself.

7. **Draft the confirmation email**

   Call `gmail_create_draft` with the recipient, subject and body. The tool builds the email message for you:

   ```python
   def create_draft(event_link: str, start: datetime) -> None:
       """Create a Gmail draft with the meeting details."""
       body = (
           f"Hi,\n\n"
           f"I've scheduled '{MEETING_TITLE}' for "
           f"{start.strftime('%A, %B %d at %H:%M UTC')} ({DURATION_MINUTES} min).\n\n"
           f"Calendar link: {event_link}\n\n"
           f"Looking forward to it!"
       )

       actions.execute_tool(
           tool_name="gmail_create_draft",
           connection_name="gmail",
           identifier=USER_ID,
           tool_input={
               "to": ATTENDEE_EMAIL,
               "subject": f"Invitation: {MEETING_TITLE}",
               "body": body,
           },
       )
       print("Draft created in Gmail.")
   ```

   The script creates a draft, not a sent message. The user reviews it before sending. This is the right default for an agent — it takes the action but keeps a human in the loop for outbound communication.

8. **Wire it together**

   ```python
   def main() -> None:
       print("Authorizing Google Calendar…")
       authorize("googlecalendar")

       print("Authorizing Gmail…")
       authorize("gmail")

       print("Checking calendar availability…")
       busy_slots = get_busy_slots()

       slot = find_free_slot(busy_slots)
       if not slot:
           print(f"No free slot found in the next {SEARCH_DAYS} days.")
           return

       start, end = slot
       print(f"Found slot: {start.strftime('%A %B %d, %H:%M')} UTC")

       print("Creating calendar event…")
       event_link = create_event(start)
       print(f"Event created: {event_link}")

       print("Creating Gmail draft…")
       create_draft(event_link, start)


   if __name__ == "__main__":
       main()
   ```

## Testing

Run the agent from the command line:

```bash
python meeting_scheduler_agent.py
```

On first run, you should see two authorization prompts in sequence:

```
Authorizing Google Calendar…

Open this link to authorize googlecalendar:
https://accounts.google.com/o/oauth2/auth?...

Press Enter after completing authorization in your browser…

Authorizing Gmail…

Open this link to authorize gmail:
https://accounts.google.com/o/oauth2/auth?...

Press Enter after completing authorization in your browser…

Checking calendar availability…
Found slot: Wednesday March 11, 10:00 UTC
Creating calendar event…
Event created: https://calendar.google.com/calendar/event?eid=...
Creating Gmail draft…
Draft created in Gmail.
```

On subsequent runs, the authorization prompts are skipped and the agent goes straight to availability checking.

Verify the results:

1. Open Google Calendar — you should see the event on the chosen date
2. Open Gmail — you should see a draft in the Drafts folder with the event link

## Common mistakes

- **Connection name mismatch** — If you name the Scalekit connection `google-calendar` instead of `googlecalendar`, `get_or_create_connected_account` returns an error. The name in the Dashboard must match the `connection_name` in the script exactly.

- **Missing OAuth scopes** — If a tool call fails with `403 Forbidden`, the connection is missing a required scope. Calendar needs `https://www.googleapis.com/auth/calendar` and Gmail needs `https://www.googleapis.com/auth/gmail.compose`. Add them to the connection in the Scalekit Dashboard, and to your OAuth app if you use your own credentials, then authorize again.

- **UTC times without timezone info** — Passing a naive `datetime` (without `timezone.utc`) to `isoformat()` produces a string without a UTC offset, and Google Calendar rejects it. Always construct datetimes with `timezone.utc`.

- **`USER_ID` not matching your session** — The script uses a hardcoded `"user_123"`. In production, replace this with the actual user ID from your application's session. A mismatch means the tool calls act on the wrong user's accounts.

## Production notes

**Timezone handling** — The working-hours check (`WORK_START_HOUR`, `WORK_END_HOUR`) is UTC-only. In production, convert the user's local timezone and the attendee's timezone before searching. The `zoneinfo` module (Python 3.9+) handles this without third-party dependencies.

**Slot granularity** — The one-hour increment misses 30- and 15-minute openings. For real scheduling, use the busy intervals directly to calculate the gaps between events, then filter by minimum duration.

**Multiple calendars** — The free/busy query checks only `primary`. Users who manage work and personal calendars separately will show false availability. Add their other calendar IDs to `calendar_ids`; `googlecalendar_list_calendars` returns them.

**Draft vs send** — Creating a draft is safer for a first deployment. When you're confident in the agent's output quality, switch from `gmail_create_draft` to `gmail_send_message` to make the agent fully autonomous. Add a confirmation step before making this change.

**Error recovery** — If `create_event` succeeds but `create_draft` fails, you have an orphaned event with no follow-up email. In production, wrap the two calls in a compensation pattern: keep the event's `id` from `result.data["event"]` and delete it with `googlecalendar_delete_event` if the draft creation fails.

**Rate limits** — Google Calendar and Gmail both have per-user quotas. If your agent runs frequently for the same user, add exponential backoff around the `execute_tool` calls.

## Next steps

- **Add user input** — Replace the hardcoded `ATTENDEE_EMAIL`, `MEETING_TITLE`, and `DURATION_MINUTES` with parameters parsed from natural language using an LLM tool call.
- **Build the JavaScript equivalent** — The Node.js SDK has the same calls: `getOrCreateConnectedAccount`, `getAuthorizationLink` and `executeTool`.
- **Handle re-authorization** — If a user revokes access, the account's status is no longer `ACTIVE` and tool calls fail. Catch that, send the user a new authorization link, and retry instead of crashing.
- **Explore other connectors** — The same `authorize()` pattern works for any Scalekit-supported connector: Slack, Notion, Jira. Swap the connection name and call that connector's tools. Each connector page lists its tools.
- **Review the Scalekit AgentKit quickstart** — For a broader overview of the connected-accounts model, see the [AgentKit quickstart](https://docs.scalekit.com/agentkit/quickstart).

## API reference

The endpoints this page's code calls, with every field and the SDK method for each:

- `POST` [Create a connected account](https://docs.scalekit.com/agentkit/reference/connected-accounts/create-a-connected-account.md)
- `GET` [Get a connected account's credentials](https://docs.scalekit.com/agentkit/reference/connected-accounts/get-connected-account-credentials.md)
- `POST` [Get an authorization link](https://docs.scalekit.com/agentkit/reference/authorization/get-an-authorization-link.md)
- `POST` [Execute a tool](https://docs.scalekit.com/agentkit/reference/tools/execute-a-tool.md)