Custom tools
Build tools that Scalekit does not provide out of the box by proxying provider API calls through connected accounts.
When you need a connector tool that Scalekit doesn’t offer as a pre-built tool, use API Proxy mode. You define the tool contract and call the provider endpoint through actions.request. Scalekit injects the user’s credentials from their connected account; your agent never handles raw tokens.
| Option | Best for | Who defines tool schema |
|---|---|---|
| Scalekit optimized tools | Common connector tools | Scalekit |
| Custom tools (API Proxy) | Unsupported or app-specific tools | Your application |
This page assumes the user has an ACTIVE connected account. If not, see Authorize a user.
Find the right endpoint
Section titled “Find the right endpoint”The path you pass to actions.request is forwarded directly to the provider’s API; Scalekit only adds authentication headers. Look up the provider’s API reference to get the correct path, method, and request shape.
| Connector | API reference |
|---|---|
| Gmail | Google Gmail API |
| Slack | Slack API methods |
| GitHub | GitHub REST API |
| Salesforce | Salesforce REST API |
| HubSpot | HubSpot API |
Define your tool contract
Section titled “Define your tool contract”Design the tool around your agent’s intent, not the provider’s API surface. For example, to list Gmail filters:
- Tool name:
gmail_list_filters(describes the action, not the endpoint) - Input:
identifier(your app’s user ID) - Output:
{ filters: [...], count: N }(structured, not the raw Gmail response)
Keep schemas focused on what the model needs. Strip provider-specific noise before returning data.
Proxy the API call
Section titled “Proxy the API call”Use actions.request to call any provider endpoint. Scalekit handles credential injection.
GET requests: pass query parameters as a dict:
def gmail_list_filters(identifier: str): response = actions.request( connection_name="gmail", identifier=identifier, method="GET", path="/gmail/v1/users/me/settings/filters", ) data = response.json() return {"filters": data.get("filter", []), "count": len(data.get("filter", []))}
def gmail_list_unread(identifier: str, max_results: int = 10): response = actions.request( connection_name="gmail", identifier=identifier, method="GET", path="/gmail/v1/users/me/messages", query_params={"q": "is:unread", "maxResults": max_results}, ) return {"messages": response.json().get("messages", [])}async function gmailListFilters(identifier: string) { const response = await scalekit.actions.request({ connectionName: 'gmail', identifier, method: 'GET', path: '/gmail/v1/users/me/settings/filters', }); const filters = response.data?.filter ?? []; return { filters, count: filters.length };}
async function gmailListUnread(identifier: string, maxResults = 10) { const response = await scalekit.actions.request({ connectionName: 'gmail', identifier, method: 'GET', path: '/gmail/v1/users/me/messages', queryParams: { q: 'is:unread', maxResults }, }); return { messages: response.data?.messages ?? [] };}POST requests: pass a body for write operations:
def slack_send_message(identifier: str, channel: str, text: str): response = actions.request( connection_name="slack", identifier=identifier, method="POST", path="/api/chat.postMessage", body={"channel": channel, "text": text}, ) data = response.json() if not data.get("ok"): raise ValueError(f"Slack error: {data.get('error')}") return {"ts": data.get("ts"), "channel": data.get("channel")}async function slackSendMessage(identifier: string, channel: string, text: string) { const response = await scalekit.actions.request({ connectionName: 'slack', identifier, method: 'POST', path: '/api/chat.postMessage', body: { channel, text }, }); if (!response.data?.ok) throw new Error(`Slack error: ${response.data?.error}`); return { ts: response.data.ts, channel: response.data.channel };}Expose as an LLM-callable tool
Section titled “Expose as an LLM-callable tool”Define a JSON Schema for each custom tool and pass the list to your LLM. The schema format below matches what Scalekit returns for built-in tools, so it works directly with the Anthropic API. For OpenAI, rename input_schema to parameters.
custom_tools = [ { "name": "gmail_list_filters", "description": "List Gmail filters configured for the current user", "input_schema": { "type": "object", "properties": {"identifier": {"type": "string"}}, "required": ["identifier"], }, }, { "name": "slack_send_message", "description": "Send a message to a Slack channel on behalf of the user", "input_schema": { "type": "object", "properties": { "identifier": {"type": "string"}, "channel": {"type": "string"}, "text": {"type": "string"}, }, "required": ["identifier", "channel", "text"], }, },]
def route_tool_call(tool_name: str, tool_input: dict): if tool_name == "gmail_list_filters": return gmail_list_filters(tool_input["identifier"]) if tool_name == "slack_send_message": return slack_send_message(tool_input["identifier"], tool_input["channel"], tool_input["text"]) raise ValueError(f"Unknown tool: {tool_name}")const customTools = [ { name: 'gmail_list_filters', description: 'List Gmail filters configured for the current user', input_schema: { type: 'object', properties: { identifier: { type: 'string' } }, required: ['identifier'], }, }, { name: 'slack_send_message', description: 'Send a message to a Slack channel on behalf of the user', input_schema: { type: 'object', properties: { identifier: { type: 'string' }, channel: { type: 'string' }, text: { type: 'string' }, }, required: ['identifier', 'channel', 'text'], }, },];
function routeToolCall(toolName: string, toolInput: Record<string, string>) { if (toolName === 'gmail_list_filters') return gmailListFilters(toolInput.identifier); if (toolName === 'slack_send_message') return slackSendMessage(toolInput.identifier, toolInput.channel, toolInput.text); throw new Error(`Unknown tool: ${toolName}`);}Check authorization before proxy calls
Section titled “Check authorization before proxy calls”Verify the connected account is ACTIVE before making a proxy call and handle provider errors explicitly:
account = actions.get_or_create_connected_account( connection_name="gmail", identifier=identifier,).connected_account
if account.status != "ACTIVE": raise ValueError("Connected account is not ACTIVE. Re-authorize the user.")import { ConnectorStatus } from '@scalekit-sdk/node/lib/pkg/grpc/scalekit/v1/connected_accounts/connected_accounts_pb';
const account = (await scalekit.actions.getOrCreateConnectedAccount({ connectionName: 'gmail', identifier,})).connectedAccount;
if (account?.status !== ConnectorStatus.ACTIVE) { throw new Error('Connected account is not ACTIVE. Re-authorize the user.');}Best practices
Section titled “Best practices”- Expose only the fields your model needs; keep schemas small
- Validate inputs server-side; never trust model-generated parameters
- Use predictable JSON keys; return stable output across calls
- Map provider errors to clear tool errors; don’t leak raw provider payloads to prompts