Announcing CIMD support for MCP Client registration
Learn more

Authenticated Tool Calling with the Anthropic SDK

TL;DR

  • The Anthropic SDK provides everything you need for agent reasoning and tool orchestration, but it does not handle authentication with external systems such as Slack or Sanity on behalf of real users.
  • Production AI agents require OAuth flows, encrypted token storage, refresh handling, revocation management, and user-scoped connector permissions for every integration they touch.
  • Managing authentication separately for every connector quickly becomes one of the largest infrastructure burdens in multi-tool AI systems.
  • ScaleKit AgentKit abstracts the authentication layer entirely, handling OAuth, token lifecycle management, encrypted credential storage, and connector execution through a consistent, provider-agnostic pattern.
  • Together, the Anthropic SDK and ScaleKit separate reasoning from authentication infrastructure, enabling the development of production-ready multi-connector agents without maintaining custom OAuth systems for each integration.

Building AI agents that reason through workflows is relatively straightforward today. The difficult part begins when those agents need authenticated access to real users' systems, such as Slack, Sanity, GitHub, or Notion. The Anthropic SDK provides a clean foundation for tool orchestration and multi-step reasoning, but it intentionally stops at the model boundary. It does not manage OAuth flows, token refresh, encrypted credential storage, or user-scoped connector execution.

In this post, we will build a production-ready editorial briefing agent using the Anthropic SDK and ScaleKit AgentKit to demonstrate what AI agent architecture looks like when authentication is treated as a dedicated infrastructure layer. The agent will query draft content and scheduled releases from Sanity and post a structured briefing into Slack, fully authenticated on behalf of a real user without custom OAuth infrastructure in the application itself.

What the Anthropic SDK gives you and the gap it leaves

The Anthropic SDK gives you a clean foundation for building reasoning-driven agents. Claude can decide which tools to call, in what order, and how to adapt based on the results it receives at each step. The overall agent loop stays remarkably small even as workflows become more complex.

Here is what that loop looks like at its core:

import Anthropic from '@anthropic-ai/sdk'; const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); const messages: Anthropic.MessageParam[] = [ { role: 'user', content: 'Post today\'s editorial briefing to Slack' } ]; while (true) { const response = await anthropic.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 2048, tools: myTools, messages, }); if (response.stop_reason === 'end_turn') break; // handle tool calls and feed results back }

With only a small amount of orchestration logic, Claude can reason across multiple systems until the task is complete. The Anthropic SDK stays focused on the model interaction layer and keeps the execution loop remarkably small.

What the Anthropic SDK Does Not Handle

The Anthropic SDK focuses on reasoning and tool orchestration, not authentication infrastructure. Before an agent can query Slack, Sanity, GitHub, or other external systems, it still needs authenticated access on behalf of a real user.

That introduces infrastructure most agent projects underestimate:

  • OAuth authorization flows
  • Encrypted token storage
  • Access token refresh handling
  • Revocation management
  • User-scoped permissions
  • Provider-specific connector logic

As more integrations are added, the authentication infrastructure can quickly become more operationally complex than the agent logic itself. That is the gap ScaleKit is designed to solve.

How ScaleKit Completes the Authentication Layer

ScaleKit AgentKit is designed specifically for the infrastructure layer that production AI agents are missing. Instead of implementing OAuth handling separately for each provider, ScaleKit exposes a consistent connector model that works the same way across systems such as Slack, Sanity, GitHub, and Notion.

The integration flow reduces to three steps:

  • Create or retrieve a connected account for a user
  • Redirect the user through the provider's OAuth flow
  • Execute authenticated tools through a shared interface

ScaleKit handles the token exchange, encrypted credential storage, refresh lifecycle, connector routing, and revocation state behind the scenes. Your application code never directly manages access tokens or refresh logic.

One of the most important details is that ScaleKit returns tool definitions already formatted in Anthropic's native input_schema structure. There is no schema conversion layer between the connector system and the Anthropic SDK.

Fetching tools and passing them directly into Claude:

const { tools } = await scalekit.tools.listScopedTools(userId, { filter: { connectionNames: ['sanity', 'slack'] }, pageSize: 100, }); const llmTools = tools.map(t => ({ name: t.tool.definition.name, description: t.tool.definition.description, input_schema: t.tool.definition.input_schema, })); await anthropic.messages.create({ tools: llmTools, messages, model, max_tokens });

Executing any tool Claude calls uses the same execution path regardless of the connector:

const result = await scalekit.actions.executeTool({ toolName: block.name, identifier: userId, toolInput: block.input, connector: connectionName, // explicit routing when a user has multiple connected accounts });

Whether the request goes to Slack or Sanity, the execution pattern stays identical. Adding another connector changes the configuration, not the orchestration architecture.

Using a Real Workflow to Demonstrate the Authentication Layer

To make the architecture concrete, we will build an Editorial Briefing Agent that connects Sanity and Slack through authenticated user-scoped connectors. Every morning, the agent queries Sanity for draft documents awaiting review, recently published content, and scheduled releases, then posts a structured briefing into a Slack channel for the editorial team.

The workflow itself is intentionally simple. The goal is not to demonstrate complex agent reasoning, but to show how authenticated multi-connector execution works when the OAuth and token lifecycle infrastructure is abstracted away from the agent logic.

The agent uses two ScaleKit connectors:

Connector
Tool
What it does
Sanity
sanitymcp_list_projects
Finds the right project by name
Sanity
sanitymcp_query_documents
GROQ queries for drafts and recently published
Sanity
sanitymcp_list_releases
Scheduled content releases
Slack
slack_send_message
Posts the formatted briefing to a channel by ID

Claude determines the order of tool calls, adapts the GROQ queries to the project schema, and dynamically formats the Slack message based on the returned data. Your application code focuses only on connector authorization, tool execution, and returning results back into the Anthropic tool loop.

The Authentication Architecture Behind the Agent

The overall agent flow separates cleanly into three phases. Breaking the workflow apart this way makes it easier to distinguish what runs once during authorization, what runs at startup, and what repeats inside the runtime execution loop.

Phase 1: Connector Authorization

This phase runs once per user per connector. ScaleKit handles the OAuth exchange, securely stores credentials, and tracks connector state for each user account.

Once authorization is complete, the agent can continue running without prompting the user again. After this phase, both connectors show ACTIVE status in your ScaleKit dashboard, and the agent can run without prompting the user again.

Phase 2: Scoped Tool Discovery

At the start of each execution, ScaleKit returns the tools available for the authenticated user across the configured connectors. The tools already match Anthropic's native input_schema structure, so they can be passed directly into the SDK without any transformation layer.

The agent intentionally filters the tool list down to only the capabilities required for the workflow. This keeps Claude's context smaller and prevents unnecessary access to tools during execution.

Phase 3: Claude-Driven Tool Execution

Once the tools are loaded, Claude decides which connector to call, in what order, and with what inputs. Your application executes each tool request through a shared ScaleKit execution path and returns the results back into the Anthropic loop until the workflow is complete.

How the agent loop decides what to do next

Inside Phase 3, the Anthropic execution loop reduces to a simple repeating decision:

  • Claude either returns a tool call
  • Or Claude signals that the workflow is complete with end_turn

Every tool request, whether it targets Sanity or Slack, passes through the same execution function before the result is returned into the loop.

This keeps the orchestration layer extremely small, even as additional connectors are added.

Project setup: Packages, environment variables, and ScaleKit connections

The implementation uses a small TypeScript project that includes the Anthropic SDK for reasoning and the ScaleKit AgentKit for authenticated connector execution.

Install the two packages the agent depends on:

mkdir editorial-briefing-agent && cd editorial-briefing-agent npm init -y npm install @scalekit-sdk/node @anthropic-ai/sdk dotenv npm install -D tsx typescript @types/node

Before writing any code, go to scalekit, open AgentKit > Connections > Create Connection, and create one connection for Sanity and one for Slack. Copy each Connection name exactly as it appears in the dashboard; it typically includes a short ID suffix, such as sanitymcp-E1RsLhxV. The value you pass in code must match character for character.

Once both connections are created, retrieve your API credentials from Settings. You will need the environment URL, client ID, and client secret.

Create your environment file:

# .env SCALEKIT_ENV_URL=https://your-env.scalekit.dev SCALEKIT_CLIENT_ID=skc_xxxxxxxxxxxxxxxx SCALEKIT_CLIENT_SECRET=test_xxxxxxxxxxxxxxxx # Connection names -- copy exactly from Dashboard → AgentKit → Connections SANITY_CONNECTION=sanitymcp-XXXXXXXX SLACK_CONNECTION=slack-XXXXXXXX ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxx USER_ID=user@example.com SANITY_PROJECT_NAME=my-sanity-project SLACK_CHANNEL=C0XXXXXXXXX # Slack channel ID, not channel name

The project has three source files:

editorial-briefing-agent/ ├── .env ├── package.json └── src/ ├── config.ts validates env at startup, fails with a readable error if anything is missing ├── auth.ts ScaleKit client, connector auth, tool listing, tool execution └── agent.ts Anthropic messages loop, system prompt, tool dispatch

Implementation

The implementation is intentionally split into small, focused steps. The goal is to keep the reasoning layer, authentication layer, and runtime orchestration clearly separated while maintaining a minimal execution loop.

The agent itself consists of three core responsibilities:

The following sections walk through each layer of the implementation step by step.

Step 1: Validate the environment at startup

The first thing the application does is validate all required environment variables before any connector or model initialization begins. Failing early with a readable configuration error is significantly easier to debug than discovering missing credentials at runtime.

// src/config.ts import 'dotenv/config'; const REQUIRED = [ 'SCALEKIT_ENV_URL', 'SCALEKIT_CLIENT_ID', 'SCALEKIT_CLIENT_SECRET', 'SANITY_CONNECTION', 'SLACK_CONNECTION', 'ANTHROPIC_API_KEY', 'USER_ID', 'SANITY_PROJECT_NAME', 'SLACK_CHANNEL', ] as const; type RequiredKey = (typeof REQUIRED)[number]; function validate(): Record { const missing = REQUIRED.filter(k => !process.env[k]); if (missing.length) { throw new Error(`Missing environment variables:\n` + missing.map(k => ` - ${k}`).join('\n') ); } return Object.fromEntries( REQUIRED.map(k => [k, process.env[k]!]) ) as Record; } export const config = validate();

Centralizing configuration validation also keeps the rest of the application simpler because every module can assume the required credentials and connector configuration already exist.

Step 2: Initialize the ScaleKit client

The ScaleKit client is created once and shared across the application. All connector authorization, tool discovery, and authenticated execution flows through this single client instance.

Keeping authentication logic centralized in one module prevents OAuth handling from leaking into the agent orchestration layer and keeps the runtime execution loop focused entirely on Claude's reasoning process.

// src/auth.ts import { ScalekitClient } from '@scalekit-sdk/node'; import { ConnectorStatus } from '@scalekit-sdk/node/lib/pkg/grpc/scalekit/v1/connected_accounts/connected_accounts_pb.js'; import Anthropic from '@anthropic-ai/sdk'; import * as readline from 'readline'; import { config } from './config.js'; export const scalekit = new ScalekitClient( config.SCALEKIT_ENV_URL, config.SCALEKIT_CLIENT_ID, config.SCALEKIT_CLIENT_SECRET, );

This client becomes the shared interface for connector authorization, tool retrieval, and authenticated tool execution throughout the rest of the application.

Step 3: Authorize each connector

The authorization function is the same for Sanity and Slack. On the first run, it prints an OAuth URL and waits. On every run after that, it finds the account already ACTIVE and returns in a single round trip:

export async function authorizeConnector( connectionName: string, userId: string, ): Promise { const { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier: userId, }); if (connectedAccount?.status === ConnectorStatus.ACTIVE) { console.log(`✓ "${connectionName}" already active`); return; } const { link } = await scalekit.actions.getAuthorizationLink({ connectionName, identifier: userId, }); console.log(`Authorize "${connectionName}":\n ${link}\n`); // In a web app: redirect the user to the link. // In this CLI, wait for them to complete, then press Enter. await pressEnter('Press Enter once you have completed the OAuth flow...'); const { connectedAccount: updated } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier: userId, }); if (updated?.status !== ConnectorStatus.ACTIVE) { throw new Error(`"${connectionName}" is not ACTIVE after authorization.`); } console.log(`✓ "${connectionName}" is now active`); }

Step 4: Fetch and execute tools

These two functions complete the ScaleKit integration. The connector routing in executeTool uses the tool name prefix to select the right connection explicitly, which is the correct pattern when a user has multiple accounts for the same provider:

export async function listScopedTools( userId: string, connectionNames: string[], ): Promise { const { tools } = await scalekit.tools.listScopedTools(userId, { filter: { connectionNames }, pageSize: 100, }); return tools.map(t => ({ name: t.tool.definition.name as string, description: t.tool.definition.description as string, input_schema: t.tool.definition.input_schema as Anthropic.Tool['input_schema'], })); } export async function executeTool( toolName: string, toolInput: Record, userId: string, ): Promise { // Route to the correct connector by tool name prefix const connector = toolName.startsWith('slack_') ? config.SLACK_CONNECTION : toolName.startsWith('sanitymcp_') ? config.SANITY_CONNECTION : undefined; const result = await scalekit.actions.executeTool({ toolName, identifier: userId, toolInput, ...(connector && { connector }), }); return JSON.stringify(result.data); }

Step 5: Filter to the tools this agent needs

Sanity exposes over 35 tools. Passing all of them to Claude wastes context and risks invoking schema management or document-creation tools that have no place in a read-and-notify workflow. The NEEDED set keeps things focused:

// src/agent.ts import Anthropic from '@anthropic-ai/sdk'; import { config } from './config.js'; import { authorizeConnector, listScopedTools, executeTool } from './auth.js'; const TODAY = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const NEEDED = new Set([ 'sanitymcp_list_projects', 'sanitymcp_query_documents', 'sanitymcp_list_releases', 'slack_send_message', ]);

Step 6: Write the system prompt

The system explicitly prompts for each tool's name, defines the GROQ queries Claude should use, and passes the Slack channel ID directly, so the agent does not need to search for it. Being specific about the query structure keeps the output consistent across runs:

function buildSystemPrompt(): string { return `You are an editorial briefing agent. Today is ${TODAY}. You have Sanity tools (sanitymcp_list_projects, sanitymcp_query_documents, sanitymcp_list_releases) and Slack tools (slack_send_message). Steps in order: 1. sanitymcp_list_projects -- find project named "${config.SANITY_PROJECT_NAME}", note projectId 2. sanitymcp_query_documents -- GROQ: *[_id in path("drafts.**")] | order(_updatedAt desc) [0...25] { _id, _type, title, _updatedAt, slug } 3. sanitymcp_query_documents -- GROQ: *[!(_id in path("drafts.**")) && _updatedAt > "${SEVEN_DAYS_AGO}"] | order(_updatedAt desc) [0...10] { _id, _type, title, _updatedAt } 4. sanitymcp_list_releases -- list scheduled releases for the project 5. Identify: drafts awaiting review, published this week, scheduled releases, stale drafts (not updated in 14+ days) 6. slack_send_message -- post the briefing to channel ID "${config.SLACK_CHANNEL}" using this format: *Editorial Briefing -- ${TODAY}* *Drafts Awaiting Review (N)* • [type] "[title]" -- last edited [relative date] *Published This Week (N)* • "[title]" -- published [relative date] *Scheduled Releases (N)* • [release name] -- scheduled for [date] *Stale Drafts (N)* • [type] "[title]" -- not updated in N days _Total: N drafts_ Omit sections with zero items. Use only data from tool results.`; }

Step 7: Authorize connectors and build the tool list

The main function checks both connectors, verifies all required tools were returned, and sets up the conversation before the loop begins:

async function main() { await authorizeConnector(config.SANITY_CONNECTION, config.USER_ID); await authorizeConnector(config.SLACK_CONNECTION, config.USER_ID); const allTools = await listScopedTools(config.USER_ID, [ config.SANITY_CONNECTION, config.SLACK_CONNECTION, ]); const tools = allTools.filter(t => NEEDED.has(t.name)); if (tools.length < NEEDED.size) { const found = new Set(tools.map(t => t.name)); const missing = [...NEEDED].filter(n => !found.has(n)); throw new Error(`Missing tools from ScaleKit: ${missing.join(', ')}`); } const anthropic = new Anthropic({ apiKey: config.ANTHROPIC_API_KEY }); const messages: Anthropic.MessageParam[] = [ { role: 'user', content: `Run the editorial briefing and post it to channel ${config.SLACK_CHANNEL}.` } ];

Step 8: The agent loop

Every tool call, whether it is a Sanity GROQ query or a Slack message, passes through executeTool without any branching on tool type. ScaleKit resolves the connector from the routing logic and handles the rest:

for (let i = 0; i < 20; i++) { const response = await anthropic.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 4096, system: buildSystemPrompt(), tools: tools as Anthropic.Tool[], messages, }); if (response.stop_reason === 'end_turn') { const text = response.content .filter((b): b is Anthropic.TextBlock => b.type === 'text') .map(b => b.text).join('\n'); console.log(text); break; } const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const block of response.content) { if (block.type !== 'tool_use') continue; console.log(`calling ${block.name}`); try { const result = await executeTool( block.name, block.input as Record, config.USER_ID, ); toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result }); } catch (err) { toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Error: ${err instanceof Error ? err.message : err}`, is_error: true, }); } } messages.push({ role: 'assistant', content: response.content }); messages.push({ role: 'user', content: toolResults }); } } main().catch(e => { console.error(e.message); process.exit(1); });

Running the Editorial Briefing Agent

npx tsx src/agent.ts

On the first run, the agent pauses at each connector, prints an authorization URL, and waits. Once both are authorized, it proceeds straight to the agent loop. Every subsequent run finds both connectors already active and goes straight to work.

Terminal output from a real run:

Editorial Briefing Agent Project : scalekit Channel : #C0B6U0BK536 Date : Thursday, May 28, 2026 ══════════════════════════════════════════════════════════ [auth] ✓ "sanitymcp-E1RsLhxV" already active [auth] ✓ "slack-sKfekCVz" already active [agent] Fetching scoped tools... [agent] Tools ready: sanitymcp_query_documents, sanitymcp_list_releases, sanitymcp_list_projects, slack_send_message [agent] iteration 1 [tool] → sanitymcp_list_projects ✓ [agent] iteration 2 [tool] → sanitymcp_query_documents ✓ 7 drafts [agent] iteration 3 [tool] → sanitymcp_query_documents ✓ 10 published [agent] iteration 4 [tool] → sanitymcp_list_releases ✓ 2 releases [agent] iteration 5 [tool] → slack_send_message ✓ posted ══════════════════════════════════════════════════════════ DONE ══════════════════════════════════════════════════════════ Total documents read: 17 (7 drafts + 10 published) Drafts Awaiting Review: 7 | Published This Week: 5 | Scheduled Releases: 2 Slack message posted -- timestamp: 1779969875.653609

The message that appears in Slack:

The entire workflow from authenticated Sanity queries to Slack delivery runs through the same Anthropic tool-use loop, with ScaleKit handling connector authorization and token management behind the scenes.

Conclusion

The Anthropic SDK is a clean foundation for agent work. The tool use loop is straightforward, Claude's reasoning across multi-step tasks is reliable, and the API stays out of your way. But the SDK ends at the model boundary. It does not know how to authenticate your agent against Sanity or Slack, and building that authentication layer yourself is a meaningful amount of work that scales poorly as you add more connectors.

ScaleKit fills that gap with a consistent three-step pattern that works the same way for every provider. The authorization flow for Sanity is identical to the one for Slack. Tool schemas come back in a format Anthropic already understands. The execution method is a single function call, regardless of which connector handles the request. Adding a third integration, whether that is Notion, GitHub, or HubSpot, means creating a connection in the ScaleKit dashboard and adding its name in two places in your code.

The combination produces something you can maintain. The intelligence layer focuses on content reasoning. The authentication layer focuses on credentials and the token lifecycle. Between the two, you get an agent that reads real CMS data, posts to real Slack channels, and stays authenticated correctly for each user, without the infrastructure weight that typically makes this kind of workflow agent impractical to ship and keep running.

Frequently Asked Questions

How are OAuth tokens stored and protected?

ScaleKit encrypts access and refresh tokens at rest, scoped to individual user identifiers. Credentials from one user cannot be accessed in the context of another. Token refresh is handled by your application code, which never touches the token directly, and requires no refresh logic of its own.

Do I need to register OAuth applications with each provider?

ScaleKit provides managed OAuth credentials for supported connectors, allowing you to validate the integration without registering your own application. For production deployments, ScaleKit recommends configuring your own OAuth client credentials in the dashboard once. The application code is identical in both cases.

What happens when a user revokes authorization?

Revoked accounts exit ACTIVE status immediately. Because getOrCreateConnectedAccount runs at the start of every agent execution, the revocation is detected on the next run, and a new authorization link is generated automatically. No additional error handling is required in your application code.

Why are GROQ queries written explicitly in the system prompt?

GROQ is expressive enough that Claude can produce many syntactically valid queries returning different document shapes. Writing queries explicitly guarantees consistent field selection across all runs and makes them straightforward to update without altering agent logic.

Can this agent run on a schedule?

Yes. The agent is a standard Node.js process that exits on completion, so any scheduler works with cron, GitHub Actions, Cloud Run Jobs, or Lambda with EventBridge. ScaleKit refreshes tokens in the background, so credentials remain valid between scheduled runs regardless of how long the agent has been idle.

No items found.
Agent Auth Quickstart
Share this article
Agent Auth Quickstart

Acquire enterprise customers with zero upfront cost

Every feature unlocked. No hidden fees.
Start Free
$0
/ month
1 million Monthly Active Users
100 Monthly Active Organizations
1 SSO connection
1 SCIM connection
10K Connected Accounts
Unlimited Dev & Prod environments