( 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}
10Public repositories work with any connected GitHub account. Private repositories only work if the connected account has access.
13 14 15 16 17 45 46 `; 47 } ``` ## 10. Run locally [Section titled “10. Run locally”](#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**. 6. Finish the GitHub OAuth flow and return to the app. 7. Confirm the page shows **GitHub connected** and the button label changes to **Reconnect GitHub**. 8. Enter `owner` and `repo`, then generate a summary. When the callback succeeds, the page shows a connected banner. The browser session cookie is `HttpOnly`, and the server rejects tampered cookies because they fail HMAC validation. Public repositories work with any connected GitHub account. Private repositories only work if the connected account has access. ## 11. Deploy to Render [Section titled “11. Deploy to Render”](#11-deploy-to-render) Render deploys the app as a web service from `render.yaml`. Set these environment variables in Render: * `LITELLM_API_KEY` * `LITELLM_BASE_URL` * `LITELLM_MODEL` * `SCALEKIT_ENVIRONMENT_URL` * `SCALEKIT_CLIENT_ID` * `SCALEKIT_CLIENT_SECRET` * `GITHUB_CONNECTION_NAME` * `SESSION_SECRET` * `PUBLIC_BASE_URL` When `PUBLIC_BASE_URL` is set, use the deployed service origin, for example `https://your-service.onrender.com`. If `PUBLIC_BASE_URL` is omitted, the app falls back to the incoming request origin for the OAuth callback URL. When you deploy from the included `render.yaml`, Render auto-generates `SESSION_SECRET` for you. ## Production notes [Section titled “Production notes”](#production-notes) * **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 [Section titled “Next steps”](#next-steps) * Read [user verification for connected accounts](/agentkit/user-verification/) for the full verification model and additional examples. * 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. --- # DOCUMENT BOUNDARY --- # 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. If you’re using a service account rather than user-delegated OAuth, or building in JavaScript, the pattern is the same but the source differs — see the `javascript/` track in [agent-auth-examples](https://github.com/scalekit-developers/agent-auth-examples). The complete Python source used here is `python/meeting_scheduler_agent.py` in that repo. **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 either returns an existing active account with a valid token or creates a new one and returns an authorization URL. Once the user authorizes, `get_connected_account` returns the token. From that point, Scalekit handles refresh automatically. This means your agent’s authorization step is a single function regardless of which connector you’re targeting. The rest of the code — Calendar queries, event creation, Gmail drafts — is plain HTTP with the token Scalekit provides. 1. **Set up the environment** Create a `.env` file at the project root with your Scalekit credentials: ```bash 1 SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com 2 SCALEKIT_CLIENT_ID=your-client-id 3 SCALEKIT_CLIENT_SECRET=your-client-secret ``` Install dependencies: ```bash 1 pip install scalekit-sdk python-dotenv requests ``` 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** meeting\_scheduler\_agent.py ```python 1 import os 2 import base64 3 from datetime import datetime, timezone, timedelta 4 from email.mime.text import MIMEText 5 6 import requests 7 from dotenv import load_dotenv 8 from scalekit import ScalekitClient 9 10 load_dotenv() 11 12 # Never hard-code credentials — they would be exposed in source control 13 # and CI logs. Pull them from environment variables instead. 14 scalekit_client = ScalekitClient( 15 environment_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"), 16 client_id=os.getenv("SCALEKIT_CLIENT_ID"), 17 client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), 18 ) 19 20 actions = scalekit_client.actions 21 22 # Replace with a real user identifier from your application's session 23 USER_ID = "user_123" 24 ATTENDEE_EMAIL = "attendee@example.com" 25 MEETING_TITLE = "Quick Sync" 26 DURATION_MINUTES = 60 27 SEARCH_DAYS = 3 28 WORK_START_HOUR = 9 # UTC 29 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 handles the first-run prompt and returns a valid access token: ```python 1 def authorize(connector: str) -> str: 2 """Ensure the user has an active connected account and return its access token. 3 4 On first run, this prints an authorization URL and waits for the user 5 to complete the browser OAuth flow before continuing. 6 """ 7 account = actions.get_or_create_connected_account(connector, USER_ID) 8 9 if account.status != "active": 10 auth_link = actions.get_authorization_link(connector, USER_ID) 11 print(f"\nOpen this link to authorize {connector}:\n{auth_link}\n") 12 input("Press Enter after completing authorization in your browser…") 13 account = actions.get_connected_account(connector, USER_ID) 14 15 return account.authorization_details["oauth_token"]["access_token"] ``` Call this once per connector before any API calls: ```python 1 calendar_token = authorize("googlecalendar") 2 gmail_token = authorize("gmail") ``` After the first successful authorization, `get_or_create_connected_account` returns `status == "active"` on subsequent runs and the `if` block is skipped. Scalekit refreshes expired tokens automatically. 4. **Query calendar availability** With a valid Calendar token, query the `freeBusy` endpoint to get the user’s busy intervals: ```python 1 def get_busy_slots(token: str) -> list[dict]: 2 """Fetch busy intervals for the user's primary calendar.""" 3 now = datetime.now(timezone.utc) 4 window_end = now + timedelta(days=SEARCH_DAYS) 5 6 response = requests.post( 7 "https://www.googleapis.com/calendar/v3/freeBusy", 8 headers={"Authorization": f"Bearer {token}"}, 9 json={ 10 "timeMin": now.isoformat(), 11 "timeMax": window_end.isoformat(), 12 "items": [{"id": "primary"}], 13 }, 14 ) 15 response.raise_for_status() 16 return response.json()["calendars"]["primary"]["busy"] ``` `raise_for_status()` converts 4xx and 5xx responses into exceptions, so the caller sees a clear error rather than a silent wrong result. The `busy` list contains `{"start": "...", "end": "..."}` dicts in ISO 8601 format. 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 1 def find_free_slot(busy_slots: list[dict]) -> tuple[datetime, datetime] | None: 2 """Return the first open one-hour slot during working hours in UTC. 3 4 Returns None if no slot is available in the search window. 5 """ 6 now = datetime.now(timezone.utc) 7 # Round up to the next whole hour so the candidate is always in the future 8 candidate = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) 9 window_end = now + timedelta(days=SEARCH_DAYS) 10 11 while candidate < window_end: 12 slot_end = candidate + timedelta(minutes=DURATION_MINUTES) 13 14 if WORK_START_HOUR <= candidate.hour < WORK_END_HOUR: 15 overlap = any( 16 candidate < datetime.fromisoformat(b["end"]) 17 and slot_end > datetime.fromisoformat(b["start"]) 18 for b in busy_slots 19 ) 20 if not overlap: 21 return candidate, slot_end 22 23 candidate += timedelta(hours=1) 24 25 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** Post the event to the Google Calendar API and return its HTML link, which you’ll include in the email draft: ```python 1 def create_event(token: str, start: datetime, end: datetime) -> str: 2 """Create a calendar event and return its HTML link.""" 3 response = requests.post( 4 "https://www.googleapis.com/calendar/v3/calendars/primary/events", 5 headers={"Authorization": f"Bearer {token}"}, 6 json={ 7 "summary": MEETING_TITLE, 8 "description": "Scheduled by agent", 9 "start": {"dateTime": start.isoformat(), "timeZone": "UTC"}, 10 "end": {"dateTime": end.isoformat(), "timeZone": "UTC"}, 11 "attendees": [{"email": ATTENDEE_EMAIL}], 12 }, 13 ) 14 response.raise_for_status() 15 return response.json()["htmlLink"] ``` The `htmlLink` in the response 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** Build the email body, base64-encode it, and post it to Gmail’s drafts endpoint: ```python 1 def create_draft(token: str, event_link: str, start: datetime) -> None: 2 """Create a Gmail draft with the meeting details.""" 3 body = ( 4 f"Hi,\n\n" 5 f"I've scheduled '{MEETING_TITLE}' for " 6 f"{start.strftime('%A, %B %d at %H:%M UTC')} ({DURATION_MINUTES} min).\n\n" 7 f"Calendar link: {event_link}\n\n" 8 f"Looking forward to it!" 9 ) 10 11 message = MIMEText(body) 12 message["to"] = ATTENDEE_EMAIL 13 message["subject"] = f"Invitation: {MEETING_TITLE}" 14 15 # Gmail's API requires the raw RFC 2822 message encoded as URL-safe base64 16 raw = base64.urlsafe_b64encode(message.as_bytes()).decode() 17 18 response = requests.post( 19 "https://gmail.googleapis.com/gmail/v1/users/me/drafts", 20 headers={"Authorization": f"Bearer {token}"}, 21 json={"message": {"raw": raw}}, 22 ) 23 response.raise_for_status() 24 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 1 def main() -> None: 2 print("Authorizing Google Calendar…") 3 calendar_token = authorize("googlecalendar") 4 5 print("Authorizing Gmail…") 6 gmail_token = authorize("gmail") 7 8 print("Checking calendar availability…") 9 busy_slots = get_busy_slots(calendar_token) 10 11 slot = find_free_slot(busy_slots) 12 if not slot: 13 print(f"No free slot found in the next {SEARCH_DAYS} days.") 14 return 15 16 start, end = slot 17 print(f"Found slot: {start.strftime('%A %B %d, %H:%M')} UTC") 18 19 print("Creating calendar event…") 20 event_link = create_event(calendar_token, start, end) 21 print(f"Event created: {event_link}") 22 23 print("Creating Gmail draft…") 24 create_draft(gmail_token, event_link, start) 25 26 27 if __name__ == "__main__": 28 main() ``` ## Testing [Section titled “Testing”](#testing) Run the agent from the command line: ```bash 1 python meeting_scheduler_agent.py ``` On first run, you should see two authorization prompts in sequence: ```plaintext 1 Authorizing Google Calendar… 2 3 Open this link to authorize googlecalendar: 4 https://accounts.google.com/o/oauth2/auth?... 5 6 Press Enter after completing authorization in your browser… 7 8 Authorizing Gmail… 9 10 Open this link to authorize gmail: 11 https://accounts.google.com/o/oauth2/auth?... 12 13 Press Enter after completing authorization in your browser… 14 15 Checking calendar availability… 16 Found slot: Wednesday March 11, 10:00 UTC 17 Creating calendar event… 18 Event created: https://calendar.google.com/calendar/event?eid=... 19 Creating Gmail draft… 20 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 [Section titled “Common mistakes”](#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 string you pass to `authorize()` exactly. * **Missing OAuth scopes** — If you see a `403 Forbidden` when calling the Calendar or Gmail API, the OAuth app in Google Cloud Console is missing the required scopes. Calendar needs `https://www.googleapis.com/auth/calendar` and Gmail needs `https://www.googleapis.com/auth/gmail.compose`. * **`raise_for_status()` swallowing context** — The default exception message from `requests` truncates the response body. In development, add `print(response.text)` before `raise_for_status()` to see the full error from Google. * **UTC times without timezone info** — Passing a naive `datetime` (without `timezone.utc`) to `isoformat()` produces a string without a `Z` suffix. Google Calendar rejects this with a `400` error. 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 connected account query returns the wrong user’s tokens. ## Production notes [Section titled “Production notes”](#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 `freeBusy` query checks only `primary`. Users who manage work and personal calendars separately will show false availability. Expand the `items` list to include all calendars the user has shared access to. **Draft vs send** — Creating a draft is safer for a first deployment. When you’re confident in the agent’s output quality, switch the Gmail endpoint from `/drafts` to `/messages/send` 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: track the event ID and delete it 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 `requests.post` calls. ## Next steps [Section titled “Next steps”](#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 `agent-auth-examples` repo includes a JavaScript track. Compare the two implementations to see where the patterns converge and where they differ. * **Handle re-authorization** — If a user revokes access, `get_connected_account` returns an inactive account. Add a re-authorization path to recover gracefully instead of crashing. * **Explore other connectors** — The same `authorize()` pattern works for any Scalekit-supported connector: Slack, Notion, Jira. Swap the connector name and replace the Google API calls with the target service’s API. * **Review the Scalekit agent auth quickstart** — For a broader overview of the connected-accounts model, see the [agent auth quickstart](/agentkit/quickstart). --- # DOCUMENT BOUNDARY --- # Enforce seat limits with SCIM provisioning > Block over-quota user creation and alert admins when SCIM pushes users beyond your plan seat limit. SCIM (System for Cross-domain Identity Management) provisioning runs unsupervised. When a customer’s HR system pushes user #51 to a 50-seat plan, your application will create that user unless you explicitly block it. Scalekit delivers the provisioning events; your application decides whether to act on them. This cookbook shows the two-event pattern that keeps your seat count accurate and tells admins when they need to upgrade their plan. Full Stack Auth handles this automatically This pattern applies to **Modular SCIM** customers who manage their own user database. If you use Scalekit Full Stack Auth, seat enforcement is built in — you don’t need this cookbook. ## SCIM does not enforce seat limits — your app must [Section titled “SCIM does not enforce seat limits — your app must”](#scim-does-not-enforce-seat-limits--your-app-must) Scalekit translates IdP-specific provisioning protocols into a consistent set of webhook events. It does not know your billing model, your seat limits, or which organizations have room for more users. That logic lives in your application. When a user is added in the IdP, Scalekit fires `organization.directory.user_created`. When a user is removed or deactivated, Scalekit fires `organization.directory.user_deleted`. Your webhook handler is the gate between those events and your user table. ## Two webhook events carry the full user lifecycle [Section titled “Two webhook events carry the full user lifecycle”](#two-webhook-events-carry-the-full-user-lifecycle) Both events include the `organization_id`, which lets you look up the seat limit for that specific customer. | Event | When it fires | What to do | | ------------------------------------- | --------------------------------- | ----------------------------------------------------- | | `organization.directory.user_created` | IdP adds or activates a user | Check count — create user or block and notify | | `organization.directory.user_deleted` | IdP removes or deactivates a user | Decrement count — clear any blocked-provisioning flag | ## Track a user count per organization in your database [Section titled “Track a user count per organization in your database”](#track-a-user-count-per-organization-in-your-database) Add a table that stores the provisioned user count and seat limit for each organization. The examples below use plain SQL — translate to your ORM if preferred. db/schema.sql ```sql 1 CREATE TABLE org_seat_usage ( 2 org_id TEXT PRIMARY KEY, 3 seat_limit INTEGER NOT NULL, 4 used_seats INTEGER NOT NULL DEFAULT 0 5 ); ``` Seed this table when you onboard a new customer. Update `seat_limit` whenever the customer upgrades or downgrades their plan. ## Block creation when the count reaches the limit [Section titled “Block creation when the count reaches the limit”](#block-creation-when-the-count-reaches-the-limit) The `user_created` handler increments the seat counter and creates the user only when there is room. Always return `200` to Scalekit — returning an error code causes Scalekit to retry delivery, which does not help when the block is intentional. Verify webhook signatures before processing Always verify that events come from Scalekit before acting on them. An unverified endpoint that mutates your database can be triggered by forged requests. See the [SCIM provisioning quickstart](/directory/scim/quickstart/) for how to verify signatures using the Scalekit SDK. Keep the lock inside the transaction The `SELECT ... FOR UPDATE` must run inside the same explicit transaction as the `INSERT` and `UPDATE`. In autocommit mode, a `FOR UPDATE` outside a transaction is released immediately after the select — it provides no protection against concurrent writes. * Node.js webhook-handler.ts ```ts 1 import express from 'express' 2 3 const app = express() 4 app.use(express.json()) 5 6 app.post('/webhooks/scalekit', async (req, res) => { 7 const event = req.body 8 9 if (event.type === 'organization.directory.user_created') { 10 const orgId = event.organization_id 11 const directoryUser = event.data 12 let seatLimitReached = false 13 14 // Run the check and insert in a single transaction. 15 // FOR UPDATE inside the transaction holds the lock until commit. 16 await db.transaction(async (tx) => { 17 const usage = await tx.queryOne( 18 'SELECT seat_limit, used_seats FROM org_seat_usage WHERE org_id = $1 FOR UPDATE', 19 [orgId] 20 ) 21 22 if (!usage || usage.used_seats >= usage.seat_limit) { 23 seatLimitReached = true 24 return 25 } 26 27 await tx.query( 28 'INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, $3, $4)', 29 [directoryUser.id, orgId, directoryUser.email, directoryUser.name] 30 ) 31 await tx.query( 32 'UPDATE org_seat_usage SET used_seats = used_seats + 1 WHERE org_id = $1', 33 [orgId] 34 ) 35 }) 36 37 if (seatLimitReached) { 38 // Seat limit reached — skip user creation and alert the admin. 39 await notifyAdminSeatLimitReached(orgId) 40 } 41 } 42 43 // Return 200 so Scalekit does not retry this event. 44 res.sendStatus(200) 45 }) ``` * Python webhook\_handler.py ```python 1 from flask import Flask, request 2 3 app = Flask(__name__) 4 5 @app.route('/webhooks/scalekit', methods=['POST']) 6 def handle_webhook(): 7 event = request.get_json() 8 9 if event.get('type') == 'organization.directory.user_created': 10 org_id = event['organization_id'] 11 directory_user = event['data'] 12 seat_limit_reached = False 13 14 # Run the check and insert in a single transaction. 15 # FOR UPDATE inside the transaction holds the lock until commit. 16 with db.transaction() as tx: 17 usage = tx.query_one( 18 'SELECT seat_limit, used_seats FROM org_seat_usage ' 19 'WHERE org_id = %s FOR UPDATE', 20 (org_id,) 21 ) 22 23 if not usage or usage['used_seats'] >= usage['seat_limit']: 24 seat_limit_reached = True 25 else: 26 tx.execute( 27 'INSERT INTO users (id, org_id, email, name) VALUES (%s, %s, %s, %s)', 28 (directory_user['id'], org_id, 29 directory_user['email'], directory_user['name']) 30 ) 31 tx.execute( 32 'UPDATE org_seat_usage SET used_seats = used_seats + 1 ' 33 'WHERE org_id = %s', 34 (org_id,) 35 ) 36 37 if seat_limit_reached: 38 # Seat limit reached — skip user creation and alert the admin. 39 notify_admin_seat_limit_reached(org_id) 40 41 # Return 200 so Scalekit does not retry this event. 42 return '', 200 ``` * Go webhook\_handler.go ```go 1 package main 2 3 import ( 4 "encoding/json" 5 "net/http" 6 ) 7 8 func webhookHandler(w http.ResponseWriter, r *http.Request) { 9 var event map[string]interface{} 10 if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 11 http.Error(w, "bad request", http.StatusBadRequest) 12 return 13 } 14 15 if event["type"] == "organization.directory.user_created" { 16 orgID := event["organization_id"].(string) 17 data := event["data"].(map[string]interface{}) 18 seatLimitReached := false 19 20 // Run the check and insert in a single transaction. 21 // FOR UPDATE inside the transaction holds the lock until commit. 22 tx, _ := db.Begin() 23 var seatLimit, usedSeats int 24 err := tx.QueryRow( 25 "SELECT seat_limit, used_seats FROM org_seat_usage WHERE org_id = $1 FOR UPDATE", 26 orgID, 27 ).Scan(&seatLimit, &usedSeats) 28 29 if err != nil || usedSeats >= seatLimit { 30 seatLimitReached = true 31 tx.Rollback() 32 } else { 33 tx.Exec( 34 "INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, $3, $4)", 35 data["id"], orgID, data["email"], data["name"], 36 ) 37 tx.Exec( 38 "UPDATE org_seat_usage SET used_seats = used_seats + 1 WHERE org_id = $1", 39 orgID, 40 ) 41 tx.Commit() 42 } 43 44 if seatLimitReached { 45 // Seat limit reached — skip user creation and alert the admin. 46 notifyAdminSeatLimitReached(orgID) 47 } 48 } 49 50 // Return 200 so Scalekit does not retry this event. 51 w.WriteHeader(http.StatusOK) 52 } ``` * Java WebhookController.java ```java 1 import org.springframework.web.bind.annotation.*; 2 import java.util.Map; 3 import java.util.concurrent.atomic.AtomicBoolean; 4 5 @RestController 6 public class WebhookController { 7 8 @PostMapping("/webhooks/scalekit") 9 public ResponseEntityTeam 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 Agent Auth: * 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 Agent Auth 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 Agent Auth: ```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 Agent Auth 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 ) 7 for scoped_tool in scoped_response.tools: 8 definition = MessageToDict(scoped_tool.tool).get("definition", {}) 9 print(definition.get("name")) 10 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'] }, // optional; omit for all connectors 3 }); 4 for (const tool of tools) { 5 const { name, input_schema } = tool.tool.definition; 6 console.log(name, input_schema); // JSON Schema; pass directly to your LLM 7 } ``` 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 ) 11 llm_tools = [ 12 { 13 "name": MessageToDict(t.tool).get("definition", {}).get("name"), 14 "description": MessageToDict(t.tool).get("definition", {}).get("description"), 15 "input_schema": MessageToDict(t.tool).get("definition", {}).get("input_schema", {}), 16 } 17 for t in scoped_response.tools 18 ] 19 20 # 2. Send to LLM 21 messages = [{"role": "user", "content": "Summarize my last 5 unread emails"}] 22 response = client.messages.create( 23 model="claude-sonnet-4-6", 24 max_tokens=1024, 25 tools=llm_tools, 26 messages=messages, 27 ) 28 29 # 3. Execute tool calls and feed results back 30 for block in response.content: 31 if block.type == "tool_use": 32 tool_result = actions.execute_tool( 33 tool_name=block.name, 34 identifier="user_123", 35 tool_input=block.input, 36 ) 37 messages.append({"role": "assistant", "content": response.content}) 38 messages.append({ 39 "role": "user", 40 "content": [{"type": "tool_result", "tool_use_id": block.id, "content": str(tool_result.data)}], 41 }) ``` * 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 }); 9 const llmTools = tools.map((t) => ({ 10 name: t.tool.definition.name, 11 description: t.tool.definition.description, 12 input_schema: t.tool.definition.input_schema, 13 })); 14 15 // 2. Send to LLM 16 const messages: Anthropic.MessageParam[] = [ 17 { role: 'user', content: 'Summarize my last 5 unread emails' }, 18 ]; 19 const response = await anthropic.messages.create({ 20 model: 'claude-sonnet-4-6', 21 max_tokens: 1024, 22 tools: llmTools, 23 messages, 24 }); 25 26 // 3. Execute tool calls and feed results back 27 for (const block of response.content) { 28 if (block.type === 'tool_use') { 29 const toolResult = await scalekit.actions.executeTool({ 30 toolName: block.name, 31 identifier: 'user_123', 32 toolInput: block.input as Record