( 15 options: OAuthUserConfig
& { 16 issuer: string 17 organizationId?: string 18 connectionId?: string 19 domain?: string 20 } 21 ): OAuthConfig
{ 22 const { issuer, organizationId, connectionId, domain } = options 23 24 return { 25 id: "scalekit", 26 name: "Scalekit", 27 type: "oidc", 28 issuer, 29 authorization: { 30 params: { 31 scope: "openid email profile", 32 ...(connectionId && { connection_id: connectionId }), 33 ...(organizationId && { organization_id: organizationId }), 34 ...(domain && { domain }), 35 }, 36 }, 37 profile(profile) { 38 return { 39 id: profile.sub, 40 name: profile.name ?? `${profile.given_name} ${profile.family_name}`, 41 email: profile.email, 42 image: profile.picture ?? null, 43 } 44 }, 45 style: { bg: "#6f42c1", text: "#fff" }, 46 options, 47 } 48 } ``` After PR #13392 merges, replace the local import with: ```typescript 1 import Scalekit from "next-auth/providers/scalekit" ``` ### 4. Configure `auth.ts` [Section titled “4. Configure auth.ts”](#4-configure-authts) Create `auth.ts` in your project root: ```typescript 1 import NextAuth from "next-auth" 2 import Scalekit from "./providers/scalekit" // → "next-auth/providers/scalekit" after PR #13392 3 4 export const { handlers, auth, signIn, signOut } = NextAuth({ 5 providers: [ 6 Scalekit({ 7 issuer: process.env.AUTH_SCALEKIT_ISSUER!, 8 clientId: process.env.AUTH_SCALEKIT_ID!, 9 clientSecret: process.env.AUTH_SCALEKIT_SECRET!, 10 // Routing: set one of these (see step 7 for strategy) 11 connectionId: process.env.AUTH_SCALEKIT_CONNECTION_ID, 12 }), 13 ], 14 basePath: "/auth", 15 session: { strategy: "jwt" }, 16 }) ``` `basePath: "/auth"` is required to match the redirect URI you registered in step 1. Without it, Auth.js uses `/api/auth` and the Scalekit callback will fail. ### 5. Set environment variables [Section titled “5. Set environment variables”](#5-set-environment-variables) .env.local ```bash 1 # Generate with: npx auth secret 2 AUTH_SECRET= 3 4 # From Scalekit dashboard → API Keys 5 AUTH_SCALEKIT_ISSUER=https://yourenv.scalekit.dev 6 AUTH_SCALEKIT_ID=skc_... 7 AUTH_SCALEKIT_SECRET= 8 9 # Connection ID for development routing (conn_...) 10 # In production, resolve this dynamically per tenant — see step 7 11 AUTH_SCALEKIT_CONNECTION_ID=conn_... ``` `AUTH_SECRET` is not optional. Auth.js uses it to sign JWTs and encrypt session cookies. Missing it causes sign-in to fail silently. ### 6. Wire up route handlers [Section titled “6. Wire up route handlers”](#6-wire-up-route-handlers) Create `app/auth/[...nextauth]/route.ts`: ```typescript 1 import { handlers } from "@/auth" 2 export const { GET, POST } = handlers ``` This exposes `GET /auth/callback/scalekit` and `POST /auth/signout` — the endpoints Auth.js needs. The directory must be `app/auth/` (not `app/api/auth/`) to match the `basePath` you configured. ### 7. SSO routing strategies [Section titled “7. SSO routing strategies”](#7-sso-routing-strategies) Scalekit resolves which IdP connection to activate using these params (highest to lowest precedence): ```typescript 1 Scalekit({ 2 issuer: process.env.AUTH_SCALEKIT_ISSUER!, 3 clientId: process.env.AUTH_SCALEKIT_ID!, 4 clientSecret: process.env.AUTH_SCALEKIT_SECRET!, 5 6 // Option A — exact connection (dev / single-tenant use) 7 connectionId: "conn_...", 8 9 // Option B — org's active connection (multi-tenant: look up org from user's DB record) 10 organizationId: "org_...", 11 12 // Option C — resolve org from email domain (useful at login prompt) 13 domain: "acme.com", 14 }) ``` In production, don’t hardcode these values. Store `organizationId` or `connectionId` per tenant in your database, then construct the `signIn()` call dynamically based on the authenticated user’s org: ```typescript 1 // Example: look up org at sign-in time 2 const org = await db.organizations.findByDomain(emailDomain) 3 4 await signIn("scalekit", { 5 organizationId: org.scalekitOrgId, 6 redirectTo: "/dashboard", 7 }) ``` ### 8. Trigger sign-in and read the session [Section titled “8. Trigger sign-in and read the session”](#8-trigger-sign-in-and-read-the-session) A server component reads the session, and a sign-in form triggers the flow: app/page.tsx ```typescript 1 import { auth, signIn } from "@/auth" 2 3 export default async function Home() { 4 const session = await auth() 5 6 if (session) { 7 return ( 8
Signed in as {session.user?.email}
10Click below to authorize access to your ${serviceName} account. 48 The actor will continue automatically once you complete authorization.
49 Authorize ${serviceName} → 50Returning to task — you can close this tab.
62 63 `; 64 } ``` The live view URL follows this pattern: ```text 1 https://{actorId}--{actorRunId}-{PORT}.runs.apify.net ``` Both `actorId` and `actorRunId` come from `Actor.getEnv()` — the same call that gives you `userId`. ### 7. Wire auth into the Actor entry point [Section titled “7. Wire auth into the Actor entry point”](#7-wire-auth-into-the-actor-entry-point) Pass the live view callback into `ensureNotionConnected`. The callback starts the HTTP server, stores the `markDone` function, and returns it so the polling loop can update the page when auth completes. src/main.js ```js 1 import { Actor } from 'apify'; 2 import { ScalekitClient } from '@scalekit-sdk/node'; 3 import { ensureNotionConnected } from './notionAuth.js'; 4 import { serveAuthPage } from './authServer.js'; 5 6 await Actor.init(); 7 8 const input = await Actor.getInput(); 9 const { task, llmApiKey } = input; 10 11 const { userId } = Actor.getEnv(); 12 const notionIdentifier = userId; 13 const youtubeIdentifier = 'shared-youtube'; 14 15 const scalekit = new ScalekitClient( 16 process.env.SCALEKIT_ENV_URL, 17 process.env.SCALEKIT_CLIENT_ID, 18 process.env.SCALEKIT_CLIENT_SECRET, 19 ); 20 21 await ensureNotionConnected(scalekit.actions, notionIdentifier, { 22 onMagicLink: async (link) => { 23 const { liveViewUrl, markDone } = await serveAuthPage(link, 'Notion'); 24 25 // Store the live view URL in OUTPUT so the Apify UI shows a clickable link. 26 await Actor.setValue('OUTPUT', { 27 status: 'AWAITING_NOTION_AUTH', 28 authPageUrl: liveViewUrl, 29 message: 'Open authPageUrl in your browser to authorize Notion.', 30 }); 31 32 await Actor.setStatusMessage(`ACTION REQUIRED: Authorize Notion → ${liveViewUrl}`); 33 34 return markDone; 35 }, 36 }); 37 38 // ... run the agent, push results ``` End-user experience after this change: 1. User starts the Actor run and types their task 2. **Output** panel immediately shows a clickable `authPageUrl` 3. User opens the URL and sees a branded “Authorize Notion →” button 4. After completing OAuth, the page updates to ”✅ Notion Authorized” 5. The Actor continues automatically — no re-run needed Apify web view does not auto-refresh The Actor’s live web view in the Apify Console does not refresh automatically. After completing the OAuth flow, the page may still show the “Authorize” button. Click the **auto-refresh** toggle in the web view toolbar, or open the URL in a new tab to see the updated state. The Actor itself continues regardless — it polls the account status server-side and proceeds as soon as authorization completes. ### 8. Design the input schema for end users [Section titled “8. Design the input schema for end users”](#8-design-the-input-schema-for-end-users) The Actor’s input form should show only what the end user actually needs to provide. All auth identifiers, LLM config, and internal settings stay out of the form. .actor/input\_schema.json ```json 1 { 2 "title": "Notion + YouTube AI Agent", 3 "type": "object", 4 "schemaVersion": 1, 5 "properties": { 6 "task": { 7 "title": "Task", 8 "type": "string", 9 "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'.", 10 "editor": "textarea" 11 }, 12 "llmApiKey": { 13 "title": "LLM API Key", 14 "type": "string", 15 "description": "API key for the LLM endpoint.", 16 "isSecret": true, 17 "editor": "textfield" 18 } 19 }, 20 "required": ["task", "llmApiKey"] 21 } ``` Everything else — `notionIdentifier`, `youtubeIdentifier`, timeouts, model name, base URL — is either derived at runtime (`userId`) or hardcoded and deployed as an Actor environment variable. Apify input schema does not support placeholder The Apify input schema spec does not allow a `placeholder` property on fields. Put example values in `description` instead — they appear as helper text below the field label in the Console. ### 9. Testing [Section titled “9. Testing”](#9-testing) Run locally: ```bash 1 apify run ``` Provide input in `storage/key_value_stores/default/INPUT.json`: ```json 1 { 2 "task": "List the 5 most recently edited pages in my Notion workspace", 3 "llmApiKey": "sk-..." 4 } ``` 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 1 apify push ``` On the first cloud run, the Actor outputs an `authPageUrl`. Open it, click **Authorize Notion**, and complete the OAuth flow. The Actor polls and continues automatically. On every subsequent run for the same Apify account, the token is already active and the auth step is skipped entirely. ## Common mistakes [Section titled “Common mistakes”](#common-mistakes) Tool calls fail with `permission_denied` even though the account is ACTIVE * **Symptom**: `[permission_denied] tool execution failed - forbidden access` in logs. The connected account status is ACTIVE and authorization completed successfully. * **Cause**: The underlying API is not enabled in the cloud provider console. An ACTIVE connected account means the OAuth token exists — it does not mean the API accepts calls. For YouTube, this happens when **YouTube Data API v3** is not enabled in the Google Cloud project. * **Fix**: Go to [Google Cloud Console → APIs & Services → Enabled APIs](https://console.cloud.google.com/apis/dashboard) and enable **YouTube Data API v3**. No code change or re-authorization needed — existing tokens work once the API is enabled. Authorization fails with `Error 403: org_internal` * **Symptom**: Google shows “Access blocked: \[App name] can only be used within its organization” when a user tries to authorize. * **Cause**: The Google account attempting to authorize is not listed as a test user. While the OAuth app is in “Testing” publishing status, only accounts explicitly added as test users can complete the OAuth flow. * **Fix**: Go to [Google Cloud Console → APIs & Services → OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) and add the Google account under **Test users**. No need to change the app’s publishing status or user type — just add the account and retry. Tool calls fail with `permission_denied` after adding scopes * **Symptom**: Same `permission_denied` error. The connection has the right scopes configured, but you added them after the user already authorized. * **Cause**: OAuth tokens carry the scopes that were configured at authorization time. Adding scopes to a connection does not retroactively update existing tokens. * **Fix**: Delete the connected account in the Scalekit dashboard (or via API) and have the user re-authorize. The new token will include the updated scopes. Notion page creation fails with “no parent\_page\_id/database\_id provided” * **Symptom**: The agent finds no page with the requested title and throws `Notion page "X" was not found and cannot be created because no parent_page_id/database_id was provided`. * **Cause**: Notion’s API requires a parent location for every new page. The Actor checks for a default parent in the input, then in environment variables, and throws if neither is set. * **Fix**: Set `NOTION_DEFAULT_PARENT_PAGE_ID` or `NOTION_DEFAULT_DATABASE_ID` as an Actor environment variable. To find a page ID, open the page in Notion, click **Share → Copy link**, and extract the 32-character hex string from the URL. Using email as the identifier * **Symptom**: Input form asks for user email, or the identifier is passed in as an input field * **Cause**: Treating the connected-account identifier as a user-facing concept * **Fix**: Use `Actor.getEnv().userId` as the identifier. It is stable, unique per Apify account, and requires no input from the user. Scalekit does not require an email — it accepts any unique string as an identifier. `placeholder` property causes build failure * **Symptom**: `apify push` fails with `Property schema.properties.task.placeholder is not allowed.` * **Cause**: `placeholder` is not part of the Apify input schema specification * **Fix**: Move example text into the `description` field. It appears as helper text in the Apify Console input form. `userId` is `undefined` locally * **Symptom**: Actor crashes with `Could not determine Apify user ID` during `apify run` * **Cause**: `Actor.getEnv()` does not populate `userId` in local runs * **Fix**: Fall back to a local dev value: `const notionIdentifier = userId ?? 'local-dev-user'` Stale variable name causes `ReferenceError` at runtime * **Symptom**: Actor fails with `notionUserEmail is not defined` even though you removed that field * **Cause**: The variable was renamed in some places but left in others — console logs, OUTPUT payloads, or `runAgent` arguments * **Fix**: Search the entire codebase for the old variable name before deploying. One missed reference fails at runtime, not at build time. Live view URL not available in local runs * **Symptom**: `getLiveViewUrl()` returns a broken URL during `apify run` * **Cause**: `actorId` and `actorRunId` are also `undefined` locally * **Fix**: Guard the live view server behind a check: `if (actorId && actorRunId) { ... }`. Fall back to logging the raw magic link to the console for local development. ## Production notes [Section titled “Production notes”](#production-notes) **Token persistence across runs** — Scalekit stores the OAuth token server-side keyed by `(connectionName, identifier)`. As long as `userId` is stable (it is), the user only completes the OAuth flow once. Subsequent runs call `getOrCreateConnectedAccount` and get an active account back immediately. **Token refresh** — Scalekit refreshes expired tokens automatically before returning them. You do not need to track expiry or call a refresh endpoint. **Re-authorization** — If a user revokes access in Notion’s settings, `getOrCreateConnectedAccount` returns a non-active account. The Actor generates a new magic link automatically. No code change required — the polling loop handles it the same way as a first-time auth. **Shared connectors** — The `shared-youtube` identifier works because YouTube access is the same for all users (e.g., read-only public data). Any connector where all users share the same OAuth session can use a hardcoded identifier. Private data connectors — Notion, Gmail, GitHub — should always use a per-user identifier. **Input schema changes require a redeploy** — The Apify Console reads the input schema from the deployed build. Changes to `.actor/input_schema.json` only take effect after `apify push`. ## Next steps [Section titled “Next steps”](#next-steps) * **Add more per-user connectors** — The same `ensureConnected` + `onMagicLink` pattern works for any Scalekit connector. Add a `src/githubAuth.js` following the same structure as `notionAuth.js`. * **Use built-in actions** — For connectors with Scalekit built-in tools, replace manual API calls with `scalekit.actions.executeTool`. See [all supported connectors](/agentkit/connectors/). * **Extend the input schema** — Add optional fields like `maxIterations` or `llmModel` with defaults, so power users can tune the Actor without the defaults getting in the way for casual users. * **Review the agent auth quickstart** — For a broader overview of the connected-accounts model, see the [agent auth quickstart](/agentkit/quickstart/). --- # DOCUMENT BOUNDARY --- # Building a Custom Organization Switcher > Learn how to build your own organization switcher UI for complete control over multi-tenant user experiences. When users belong to multiple organizations, the default Scalekit organization switcher handles most use cases. However, some applications require deeper integration—a custom switcher embedded directly in your app’s navigation, or a specialized UI that matches your design system. This guide shows you how to build your own organization switcher using Scalekit’s APIs. ## Why build a custom switcher? [Section titled “Why build a custom switcher?”](#why-build-a-custom-switcher) The default Scalekit-hosted switcher works well for most scenarios. Build a custom switcher when you need: * **In-app navigation**: Users switch organizations without leaving your application * **Custom branding**: The switcher matches your application’s design language * **Specialized workflows**: Your app needs org-specific logic during switches * **Reduced redirects**: Avoid sending users through the authentication flow for every switch ## How the custom switcher works [Section titled “How the custom switcher works”](#how-the-custom-switcher-works) Your application handles the entire switching flow: 1. User authenticates through Scalekit and receives a session 2. Your app fetches the user’s organizations via the User Sessions API 3. You render your own organization selector UI 4. When a user selects an organization, your app updates the active context This approach gives you full control over the UI and routing, but requires you to manage session state and organization context within your application. ## Fetch user organizations [Section titled “Fetch user organizations”](#fetch-user-organizations) The User Sessions API returns the `authenticated_organizations` field containing all organizations the user can access. Use this data to populate your switcher UI. * Node.js Express.js ```javascript 1 // Use case: Get user's organizations for your switcher UI 2 // Security: Always validate session ownership before returning org data 3 const session = await scalekit.session.getSession(sessionId); 4 5 // Extract organizations from the session response 6 const organizations = session.authenticated_organizations || []; 7 8 // Render your organization switcher with this data 9 res.json({ organizations }); ``` * Python Flask ```python 1 # Use case: Get user's organizations for your switcher UI 2 # Security: Always validate session ownership before returning org data 3 session = scalekit_client.session.get_session(session_id) 4 5 # Extract organizations from the session response 6 organizations = session.get('authenticated_organizations', []) 7 8 # Render your organization switcher with this data 9 return jsonify({'organizations': organizations}) ``` * Go Gin ```go 1 // Use case: Get user's organizations for your switcher UI 2 // Security: Always validate session ownership before returning org data 3 session, err := scalekitClient.Session().GetSession(ctx, sessionId) 4 if err != nil { 5 return err 6 } 7 8 // Extract organizations from the session response 9 organizations := session.AuthenticatedOrganizations 10 11 // Render your organization switcher with this data 12 c.JSON(http.StatusOK, gin.H{"organizations": organizations}) ``` * Java Spring ```java 1 // Use case: Get user's organizations for your switcher UI 2 // Security: Always validate session ownership before returning org data 3 Session session = scalekitClient.sessions().getSession(sessionId); 4 5 // Extract organizations from the session response 6 ListYou can close this tab and return to the app. The original page will update automatically.
8Team Size: ${report.team_size}
89Total Issues: ${report.issues.total}
90Completed Issues: ${report.issues.completed}
91In Progress: ${report.issues.in_progress}
92 ${report.meetings ? `Total Meetings: ${report.meetings.total}
` : ''} 93 94 95 `; 96 97 // Send report via email 98 const emailResults = await Promise.all( 99 team_members.map(member => 100 context.tools.execute({ 101 tool: 'send_email', 102 parameters: { 103 to: [member], 104 subject: `Team Report - ${start_date} to ${end_date}`, 105 html_body: htmlReport 106 } 107 }) 108 ) 109 ); 110 111 return { 112 report_url: 'Generated and sent via email', 113 summary: report, 114 sent_to: team_members.filter((_, index) => emailResults[index].status === 'sent') 115 }; 116 } 117 }; ``` ## Registering custom tools [Section titled “Registering custom tools”](#registering-custom-tools) ### Using the API [Section titled “Using the API”](#using-the-api) Register your custom tools with AgentKit: * JavaScript ```javascript 1 // Register a custom tool 2 const registeredTool = await agentConnect.tools.register({ 3 ...sendWelcomeEmail, 4 organization_id: 'your_org_id' 5 }); 6 7 console.log('Tool registered:', registeredTool.id); ``` * Python ```python 1 # Register a custom tool 2 registered_tool = agent_connect.tools.register( 3 **send_welcome_email, 4 organization_id='your_org_id' 5 ) 6 7 print(f'Tool registered: {registered_tool.id}') ``` * cURL ```bash 1 curl -X POST "${SCALEKIT_BASE_URL}/v1/connect/tools/custom" \ 2 -H "Authorization: Bearer ${SCALEKIT_CLIENT_SECRET}" \ 3 -H "Content-Type: application/json" \ 4 -d '{ 5 "name": "send_welcome_email", 6 "display_name": "Send Welcome Email", 7 "description": "Send a personalized welcome email to new users", 8 "category": "communication", 9 "provider": "custom", 10 "input_schema": {...}, 11 "output_schema": {...}, 12 "implementation": "async (parameters, context) => {...}" 13 }' ``` ### Using the dashboard [Section titled “Using the dashboard”](#using-the-dashboard) 1. In the [Scalekit dashboard](https://app.scalekit.com), go to **AgentKit** > **Tools** 2. Click **Create Custom Tool** 3. Fill in the tool definition form 4. Test the tool with sample parameters 5. Save and activate the tool ## Tool context and utilities [Section titled “Tool context and utilities”](#tool-context-and-utilities) The `context` object provides access to: ### Standard tools [Section titled “Standard tools”](#standard-tools) Execute any standard AgentKit tool: ```javascript 1 // Execute standard tools 2 const result = await context.tools.execute({ 3 tool: 'send_email', 4 parameters: { ... } 5 }); 6 7 // Execute with specific connected account 8 const result = await context.tools.execute({ 9 connected_account_id: 'specific_account', 10 tool: 'send_email', 11 parameters: { ... } 12 }); ``` ### Connected accounts [Section titled “Connected accounts”](#connected-accounts) Access connected account information: ```javascript 1 // Get connected account details 2 const account = await context.accounts.get(accountId); 3 4 // List accounts for a user 5 const accounts = await context.accounts.list({ 6 identifier: 'user_123', 7 provider: 'gmail' 8 }); ``` ### Utilities [Section titled “Utilities”](#utilities) Access utility functions: ```javascript 1 // Generate unique IDs 2 const id = context.utils.generateId(); 3 4 // Format dates 5 const formatted = context.utils.formatDate(date, 'YYYY-MM-DD'); 6 7 // Validate email 8 const isValid = context.utils.isValidEmail(email); 9 10 // HTTP requests 11 const response = await context.utils.httpRequest({ 12 url: 'https://api.example.com/data', 13 method: 'GET', 14 headers: { 'Authorization': 'Bearer token' } 15 }); ``` ### Error handling [Section titled “Error handling”](#error-handling) Throw structured errors: ```javascript 1 // Throw validation error 2 throw new context.errors.ValidationError('Invalid email format'); 3 4 // Throw business logic error 5 throw new context.errors.BusinessLogicError('User not found'); 6 7 // Throw external API error 8 throw new context.errors.ExternalAPIError('GitHub API returned 500'); ``` ## Testing custom tools [Section titled “Testing custom tools”](#testing-custom-tools) ### Unit testing [Section titled “Unit testing”](#unit-testing) Test custom tools in isolation: ```javascript 1 // Mock context for testing 2 const mockContext = { 3 tools: { 4 execute: jest.fn().mockResolvedValue({ 5 message_id: 'test_msg_123', 6 status: 'sent' 7 }) 8 }, 9 utils: { 10 generateId: () => 'test_id_123', 11 formatDate: (date, format) => '2024-01-15' 12 } 13 }; 14 15 // Test custom tool 16 const result = await sendWelcomeEmail.implementation({ 17 user_name: 'John Doe', 18 user_email: 'john@example.com', 19 company_name: 'Acme Corp' 20 }, mockContext); 21 22 expect(result.status).toBe('sent'); 23 expect(mockContext.tools.execute).toHaveBeenCalledWith({ 24 tool: 'send_email', 25 parameters: expect.objectContaining({ 26 to: ['john@example.com'], 27 subject: 'Welcome to Acme Corp!' 28 }) 29 }); ``` ### Integration testing [Section titled “Integration testing”](#integration-testing) Test with real AgentKit: ```javascript 1 // Test custom tool with real connections 2 const testResult = await agentConnect.tools.execute({ 3 connected_account_id: 'test_gmail_account', 4 tool: 'send_welcome_email', 5 parameters: { 6 user_name: 'Test User', 7 user_email: 'test@example.com', 8 company_name: 'Test Company' 9 } 10 }); 11 12 console.log('Test result:', testResult); ``` ## Best practices [Section titled “Best practices”](#best-practices) ### Tool design [Section titled “Tool design”](#tool-design) * **Single responsibility**: Each tool should have a clear, single purpose * **Consistent naming**: Use descriptive, consistent naming conventions * **Clear documentation**: Provide detailed descriptions and examples * **Error handling**: Implement comprehensive error handling * **Input validation**: Validate all input parameters ### Performance optimization [Section titled “Performance optimization”](#performance-optimization) * **Parallel execution**: Use Promise.all() for independent operations * **Caching**: Cache frequently accessed data * **Batch operations**: Group similar operations together * **Timeout handling**: Set appropriate timeouts for external calls ### Security considerations [Section titled “Security considerations”](#security-considerations) * **Input sanitization**: Sanitize all user inputs * **Permission checks**: Verify user permissions before execution * **Sensitive data**: Handle sensitive data securely * **Rate limiting**: Implement rate limiting for resource-intensive operations ## Custom tool examples [Section titled “Custom tool examples”](#custom-tool-examples) ### Slack notification tool [Section titled “Slack notification tool”](#slack-notification-tool) ```javascript 1 const sendSlackNotification = { 2 name: 'send_slack_notification', 3 display_name: 'Send Slack Notification', 4 description: 'Send formatted notifications to Slack with optional mentions', 5 category: 'communication', 6 provider: 'custom', 7 input_schema: { 8 type: 'object', 9 properties: { 10 channel: { type: 'string' }, 11 message: { type: 'string' }, 12 severity: { type: 'string', enum: ['info', 'warning', 'error'] }, 13 mentions: { type: 'array', items: { type: 'string' } } 14 }, 15 required: ['channel', 'message'] 16 }, 17 output_schema: { 18 type: 'object', 19 properties: { 20 message_ts: { type: 'string' }, 21 permalink: { type: 'string' } 22 } 23 }, 24 implementation: async (parameters, context) => { 25 const { channel, message, severity = 'info', mentions = [] } = parameters; 26 27 const colors = { 28 info: 'good', 29 warning: 'warning', 30 error: 'danger' 31 }; 32 33 const mentionText = mentions.length > 0 ? 34 `${mentions.map(m => `<@${m}>`).join(' ')} ` : ''; 35 36 return await context.tools.execute({ 37 tool: 'send_message', 38 parameters: { 39 channel, 40 text: `${mentionText}${message}`, 41 attachments: [ 42 { 43 color: colors[severity], 44 text: message, 45 ts: Math.floor(Date.now() / 1000) 46 } 47 ] 48 } 49 }); 50 } 51 }; ``` ### Calendar scheduling tool [Section titled “Calendar scheduling tool”](#calendar-scheduling-tool) ```javascript 1 const scheduleTeamMeeting = { 2 name: 'schedule_team_meeting', 3 display_name: 'Schedule Team Meeting', 4 description: 'Find available time slots and schedule team meetings', 5 category: 'scheduling', 6 provider: 'custom', 7 input_schema: { 8 type: 'object', 9 properties: { 10 attendees: { type: 'array', items: { type: 'string' } }, 11 duration: { type: 'number', minimum: 15 }, 12 preferred_times: { type: 'array', items: { type: 'string' } }, 13 meeting_title: { type: 'string' }, 14 meeting_description: { type: 'string' } 15 }, 16 required: ['attendees', 'duration', 'meeting_title'] 17 }, 18 output_schema: { 19 type: 'object', 20 properties: { 21 event_id: { type: 'string' }, 22 scheduled_time: { type: 'string' }, 23 attendees_notified: { type: 'number' } 24 } 25 }, 26 implementation: async (parameters, context) => { 27 const { attendees, duration, preferred_times, meeting_title, meeting_description } = parameters; 28 29 // Find available time slots 30 const availableSlots = await context.tools.execute({ 31 tool: 'find_available_slots', 32 parameters: { 33 attendees, 34 duration, 35 preferred_times: preferred_times || [] 36 } 37 }); 38 39 if (availableSlots.length === 0) { 40 throw new context.errors.BusinessLogicError('No available time slots found'); 41 } 42 43 // Schedule the meeting at the first available slot 44 const selectedSlot = availableSlots[0]; 45 const event = await context.tools.execute({ 46 tool: 'create_event', 47 parameters: { 48 title: meeting_title, 49 description: meeting_description, 50 start_time: selectedSlot.start_time, 51 end_time: selectedSlot.end_time, 52 attendees 53 } 54 }); 55 56 return { 57 event_id: event.event_id, 58 scheduled_time: selectedSlot.start_time, 59 attendees_notified: attendees.length 60 }; 61 } 62 }; ``` ## Versioning and deployment [Section titled “Versioning and deployment”](#versioning-and-deployment) ### Version management [Section titled “Version management”](#version-management) Version your custom tools for backward compatibility: ```javascript 1 const toolV2 = { 2 ...originalTool, 3 version: '2.0.0', 4 // Updated implementation 5 }; 6 7 // Deploy new version 8 await agentConnect.tools.register(toolV2); 9 10 // Deprecate old version 11 await agentConnect.tools.deprecate(originalTool.name, '1.0.0'); ``` ### Deployment strategies [Section titled “Deployment strategies”](#deployment-strategies) * **Blue-green deployment**: Deploy new version alongside old version * **Canary deployment**: Gradually roll out to subset of users * **Feature flags**: Use feature flags to control tool availability * **Rollback strategy**: Plan for quick rollback if issues arise Note **Ready to build?** Start with simple custom tools and gradually add complexity. Test thoroughly before deploying to production, and consider the impact on your users when making changes. Custom tools unlock the full potential of AgentKit by allowing you to create specialized workflows that perfectly match your business needs. With proper design, testing, and deployment practices, you can build powerful tools that enhance your team’s productivity and streamline complex operations. --- # DOCUMENT BOUNDARY --- # Scalekit optimized built-in tools > Call Scalekit's pre-built tools across 100+ connectors. Each tool returns structured, LLM-ready output with no endpoint URLs, auth headers, or parsing needed. Scalekit ships pre-built tools for every connector in the catalog: Gmail, Slack, GitHub, Salesforce, Notion, Linear, HubSpot, and more. Each tool has an LLM-ready schema and returns structured output. Your agent passes inputs; Scalekit injects the user’s credentials and handles the API call. This page assumes you have an `ACTIVE` connected account for the user. If not, see [Authorize a user](/agentkit/tools/authorize/). ## Get available tools for a user [Section titled “Get available tools for a user”](#get-available-tools-for-a-user) Use `list_scoped_tools` / `listScopedTools` to get the tools this specific user is authorized to call. **This is the list you pass to your LLM.** * Python ```python 1 from google.protobuf.json_format import MessageToDict 2 3 scoped_response, _ = actions.tools.list_scoped_tools( 4 identifier="user_123", 5 filter={"connection_names": ["gmail"]}, # optional; omit for all connectors 6 page_size=100, # fetch beyond the default page 7 ) 8 for scoped_tool in scoped_response.tools: 9 definition = MessageToDict(scoped_tool.tool).get("definition", {}) 10 print(definition.get("name")) 11 print(definition.get("input_schema")) # JSON Schema; pass directly to your LLM ``` * Node.js ```typescript 1 const { tools } = await scalekit.tools.listScopedTools('user_123', { 2 filter: { connectionNames: ['gmail'] }, // use filter: {} to list every connector 3 pageSize: 100, // fetch beyond the default page 4 }); 5 for (const tool of tools) { 6 const { name, input_schema } = tool.tool.definition; 7 console.log(name, input_schema); // JSON Schema; pass directly to your LLM 8 } ``` To explore tools interactively, use the playground at [**Scalekit Dashboard**](https://app.scalekit.com) **> AgentKit > Playground**. ## Execute a tool [Section titled “Execute a tool”](#execute-a-tool) Use `execute_tool` / `executeTool` to run a named tool for a specific user. Scalekit identifies the connected account with: * User identifier (`identifier`) + Connection name as shown in the Scalekit Dashboard (`connection_name`), or * Connected Account ID (`connected_account_id`) — autogenerated by Scalekit and visible in the Scalekit Dashboard - Python ```python 1 # connected account is selected using the user identifier and the connection name 2 result = actions.execute_tool( 3 tool_name="gmail_fetch_mails", 4 identifier="user_123", 5 connection_name="gmail", 6 tool_input={"query": "is:unread", "max_results": 5}, 7 ) 8 print(result.data) 9 10 # alternatively, use the connected account ID 11 # result = actions.execute_tool( 12 # tool_name="gmail_fetch_mails", 13 # connected_account_id="ca_xxxxxx", 14 # tool_input={"query": "is:unread", "max_results": 5}, 15 # ) ``` - Node.js ```typescript 1 // connected account is selected using the user identifier and the connector 2 const result = await scalekit.actions.executeTool({ 3 toolName: 'gmail_fetch_mails', 4 identifier: 'user_123', 5 connector: 'gmail', 6 toolInput: { query: 'is:unread', max_results: 5 }, 7 }); 8 console.log(result.data); 9 10 // alternatively, use the connected account ID 11 // const result = await scalekit.actions.executeTool({ 12 // toolName: 'gmail_fetch_mails', 13 // connectedAccountId: 'ca_xxxxxx', 14 // toolInput: { query: 'is:unread', max_results: 5 }, 15 // }); ``` ## Wire into your LLM [Section titled “Wire into your LLM”](#wire-into-your-llm) The full agent loop: fetch scoped tools → pass to LLM → execute tool calls → feed results back. * Python ```python 1 import anthropic 2 from google.protobuf.json_format import MessageToDict 3 4 client = anthropic.Anthropic() 5 6 # 1. Fetch tools scoped to this user 7 scoped_response, _ = actions.tools.list_scoped_tools( 8 identifier="user_123", 9 filter={"connection_names": ["gmail"]}, 10 page_size=100, # fetch beyond the default page so no connector tools are missed 11 ) 12 llm_tools = [ 13 { 14 "name": MessageToDict(t.tool).get("definition", {}).get("name"), 15 "description": MessageToDict(t.tool).get("definition", {}).get("description"), 16 "input_schema": MessageToDict(t.tool).get("definition", {}).get("input_schema", {}), 17 } 18 for t in scoped_response.tools 19 ] 20 21 # 2. Send to LLM 22 messages = [{"role": "user", "content": "Summarize my last 5 unread emails"}] 23 response = client.messages.create( 24 model="claude-sonnet-4-6", 25 max_tokens=1024, 26 tools=llm_tools, 27 messages=messages, 28 ) 29 30 # 3. Execute tool calls and feed results back 31 for block in response.content: 32 if block.type == "tool_use": 33 tool_result = actions.execute_tool( 34 tool_name=block.name, 35 identifier="user_123", 36 tool_input=block.input, 37 ) 38 messages.append({"role": "assistant", "content": response.content}) 39 messages.append({ 40 "role": "user", 41 "content": [{"type": "tool_result", "tool_use_id": block.id, "content": str(tool_result.data)}], 42 }) ``` * Node.js ```typescript 1 import Anthropic from '@anthropic-ai/sdk'; 2 3 const anthropic = new Anthropic(); 4 5 // 1. Fetch tools scoped to this user 6 const { tools } = await scalekit.tools.listScopedTools('user_123', { 7 filter: { connectionNames: ['gmail'] }, 8 pageSize: 100, // fetch beyond the default page so no connector tools are missed 9 }); 10 const llmTools = tools.map((t) => ({ 11 name: t.tool.definition.name, 12 description: t.tool.definition.description, 13 input_schema: t.tool.definition.input_schema, 14 })); 15 16 // 2. Send to LLM 17 const messages: Anthropic.MessageParam[] = [ 18 { role: 'user', content: 'Summarize my last 5 unread emails' }, 19 ]; 20 const response = await anthropic.messages.create({ 21 model: 'claude-sonnet-4-6', 22 max_tokens: 1024, 23 tools: llmTools, 24 messages, 25 }); 26 27 // 3. Execute tool calls and feed results back 28 for (const block of response.content) { 29 if (block.type === 'tool_use') { 30 const toolResult = await scalekit.actions.executeTool({ 31 toolName: block.name, 32 identifier: 'user_123', 33 toolInput: block.input as Record