Announcing CIMD support for MCP Client registration
Learn more

Give Your Claude Agent Slack Access: Messages, Channels, and Search

Saif Ali Shaik
Founding Developer Advocate

TL;DR

  • A complete walkthrough for building a Claude agent in TypeScript that acts on Slack as each individual user — not as a shared bot — using Scalekit to handle all auth.
  • User connects their Slack account once via OAuth (Scalekit vaults the token)
  • Your agent calls listScopedTools to get ~12 Slack tools in Anthropic's native format, no schema writing
  • Standard Claude tool-use loop: Claude decides what to call, your code calls executeTool with a user identifier, Scalekit makes the Slack API call as that specific user

Your users expect the Claude agent you've built to search Slack history, post to channels, and read conversations as their own Slack identity — not as a shared bot or integration account.

When the second person starts using your agent, a single shared connection falls apart fast: messages post under the wrong name, searches return conversations the current user shouldn't see, and replies come from the wrong person.

You don't need bot tokens, webhook plumbing, per-user token storage, or an OAuth implementation. Scalekit lets your agent act as each user natively.

Scalekit connects your agent to Slack through each user's own identity and returns tools in Anthropic's native format.

Before you start

  • Node.js 18+
  • npm install @anthropic-ai/sdk @scalekit-sdk/node
  • A Scalekit account (free to start). Sign up at scalekit.com, then in your environment dashboard:
    • Copy your Client ID and Client Secret (usually under Credentials or API Keys).
    • Note your Environment URL (e.g. https://your-env.scalekit.cloud).
    • Under Agent Auth → Connections, create a Slack connection and give it a name (e.g. slack). Use that exact name in the code. (You must also install the Scalekit Slack app in your workspace.)
  • A Slack workspace where the Scalekit app is installed
  • An Anthropic API key from console.anthropic.com (or a compatible proxy like LiteLLM)

How it fits together

You are building a Claude agent in TypeScript (using the Anthropic SDK). When one of your users chats with the agent and asks it to do something in Slack, your code calls Scalekit.

You pass an identifier that represents the current person in your own system. This identifier comes from your application's normal authentication (after you verify the user via session, JWT, database lookup, etc.). Never trust an identifier coming from the client.

Scalekit looks up the Slack connection that person previously authorized through your app. It then makes the actual Slack API call using their identity and permissions. Your code never sees or manages tokens.

The one-time connection step is also triggered from your app. When a user wants to link their Slack account so your agent can act on their behalf, you generate the authorization link using their identifier and guide them through the OAuth flow.

The complete script

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 'dotenv/config' const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!, ) const anthropic = new Anthropic({ baseURL: process.env.ANTHROPIC_BASE_URL, apiKey: process.env.ANTHROPIC_API_KEY, }) // 1. Ensure the user has a connected Slack account const { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName: 'slack', identifier: 'user_123', }) if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName: 'slack', identifier: 'user_123', }) console.log('Authorize Slack:', link) process.exit(0) } // 2. Discover Slack tools (Scalekit returns Anthropic's native format) // NOTE: 'slack' here must exactly match the Connection name you gave // the connection in the Scalekit dashboard when you created it (one-time). const { tools } = await scalekit.tools.listScopedTools('user_123', { filter: { connectionNames: ['slack'] }, }) const llmTools = tools .map((tool) => tool.tool?.definition) .filter((def): def is NonNullable => Boolean(def?.name)) .map((def) => ({ name: String(def.name), description: String(def.description ?? ''), input_schema: (def.input_schema ?? { type: 'object', properties: {} }) as Record, })) console.log(`Discovered ${llmTools.length} Slack tools`) // 3. Run the agent loop const messages: Anthropic.MessageParam[] = [ { role: 'user', content: 'Search for recent messages about deployments and summarize what the team discussed' }, ] while (true) { const response = await anthropic.messages.create({ model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6', max_tokens: 1024, tools: llmTools as never, messages, }) if (response.stop_reason === 'end_turn') { const text = response.content.find((b) => b.type === 'text') if (text?.type === 'text') console.log(text.text) break } const toolResults: Anthropic.ToolResultBlockParam[] = [] for (const block of response.content) { if (block.type === 'tool_use') { console.log(` -> Calling: ${block.name}`) const result = await scalekit.actions.executeTool({ toolName: block.name, identifier: 'user_123', toolInput: block.input as Record, }) toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(result.data), }) } } messages.push({ role: 'assistant', content: response.content }) messages.push({ role: 'user', content: toolResults }) }

Run it

You can run the script directly with tsx (recommended for top-level await + dotenv):

npx tsx agent.ts

Or compile to JS first and run with Node.

Critical: The value 'slack' (in the filter / connectionNames) must be the exact Connection name you entered when you created the Slack connection in the Scalekit dashboard (one-time step under Agent Auth → Connections). Names are case-sensitive.

Create a .env file (loaded automatically by dotenv):

# Scalekit credentials — get these from your environment dashboard # (Credentials / API Keys section) SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.cloud SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=test_... # Anthropic (or proxy) key ANTHROPIC_BASE_URL=https://api.anthropic.com ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-6

On the first run for a new identifier, the script will print an authorization link. Open it, complete the Slack OAuth (app must be installed in the workspace), then re-run the script.

You should see something like:

Discovered 12 Slack tools -> Calling: slack_search_messages Found 3 recent threads about deployments. Here's a summary: - @maya kicked off a deploy of v2.4.1 to staging on Monday...

How it works

The script is deliberately minimal. Every piece maps to one concept. (The same mental model applies if you later port it to Python.)

Connect your user

get_or_create_connected_account checks whether this identifier already has an active Slack connection. If not, get_authorization_link generates the URL the user opens to complete Slack OAuth (the Scalekit app must already be installed in the target workspace). In production the identifier always comes from your own authenticated session — never from the client.

Discover tools

list_scoped_tools returns the tools available for this user's Slack connection. The key part is the filter:

filter={"connection_names": ["slack"]}

The name you put inside the filter (e.g. "slack") must exactly match the Connection name you created in the Scalekit dashboard. It is case-sensitive.

A mismatch here is the most common reason for getting an empty tool list on the first run. The same filter pattern works for any connector.

The agent loop

This is the standard Anthropic Messages API tool-use loop:

  1. Send the conversation so far to messages.create with the current tool list.
  2. If stop_reason is end_turn, print the final text and stop.
  3. Otherwise Claude emitted one or more tool_use blocks.
  4. For each, call execute_tool via Scalekit, collect tool_result blocks.
  5. Append the assistant message and the tool results, then loop.

Claude can request multiple tools in one turn; the loop processes all of them before the next messages.create.

In production you will usually wrap the executeTool call in try/catch so transient errors or permission issues are returned to Claude as tool results instead of crashing the agent. Many teams also add simple logging of tool name + input + result for debugging and audit trails.

Tool execution

execute_tool takes the exact tool name Claude asked for, the user identifier, and the input object. Scalekit looks up the stored Slack token for that identifier and makes the real Slack Web API call. Your code never sees the token, never refreshes it, and never worries about scopes.

What a tool definition looks like

Scalekit returns each tool with name, description, and input_schema exactly as Anthropic expects. Example (search):

{ "name": "slack_search_messages", "description": "Search for messages in the user's Slack workspace", "input_schema": { "type": "object", "properties": { "query": { "type": "string", "description": "Search query" }, "sort": { "type": "string", "description": "Sort order: 'timestamp' or 'score'" } }, "required": ["query"] } }

You pass the array of these objects directly to anthropic.messages.create({ tools: llmTools, ... }).

The TypeScript SDK returns plain objects, so the code just maps over the result and does light filtering + type assertions. No protobuf conversion needed.

Available Slack tools

The Slack connector exposes a broad surface. In testing a typical connected account returns around a dozen tools. Here is the practical set grouped the way most agents use them:

Category
Tool
Description
Messages
slack_chat_postMessage
Post a message to a channel or DM (as the connected user)
slack_chat_update
Edit a message you previously sent
slack_search_messages
Full-text search across channels the user can access
Channels
slack_conversations_list
List public and private channels the user is a member of
slack_conversations_history
Read recent messages (and thread replies) from a specific channel
slack_conversations_info
Get metadata, topic, and member count for a channel
Users
slack_users_list
List workspace members (with pagination)
slack_users_info
Get profile, title, and status for a specific user
Reactions
slack_reactions_add
Add an emoji reaction to a message
Files
slack_files_list
List files shared in a channel or by a user

Exact names and the full set can evolve as the connector is improved. Scalekit keeps the definitions current against Slack's API. See the live list in the Slack connector docs.

Try these prompts

The same 50-line script handles any prompt that maps to the available tools. A few realistic ones that tend to produce interesting multi-tool or multi-turn behavior:

Search + summarize a thread:

"Find the last discussion about the Q3 launch timeline and summarize the open action items and owners."

Claude usually calls slack_search_messages first, then may call slack_conversations_history on the most relevant channel to pull full context before answering.

Post as the actual user (not a bot):

"Post a short standup summary to #eng-updates: 'Blocked on the new billing worker. Will pair with Maya tomorrow.'"

Claude calls slack_chat_postMessage. The message appears from your real Slack identity with your avatar and name. Anyone can @-reply you directly.

Channel history + follow-up question:

"What decisions were made in #product yesterday about the new onboarding flow?"

Claude calls slack_conversations_history (with a sensible oldest timestamp) and returns a concise digest with speaker attribution.

Cross-user lookup + message:

"Find Maya's Slack ID and send her a direct message asking for the latest design file link."

This typically triggers slack_users_list (or search via another tool) followed by slack_chat_postMessage to the im channel. All with the correct user token.

Claude can chain these naturally. You don't write any Slack-specific routing or identity logic — the model + the scoped tools do the work.

Search + DM a teammate:

"Find the last message from Maya about the design handoff and send her a DM asking for the Figma link."

This usually triggers slack_search_messages followed by slack_chat_postMessage (to an IM channel). It demonstrates the agent using search results to decide who to message next.

Why this already handles multiple users

The identifier parameter ("user_123" in the example) is what makes the same script safe for teams and customer-facing products. Every user in your system gets their own identifier. When they connect Slack, Scalekit stores their token under that identifier. Every search, every message sent, every history read happens with that specific user's Slack identity and permissions.

This is fundamentally different from a Slack bot. A bot posts as itself (or a shared integration user). With Scalekit your agent posts as the actual human, searches only the channels and DMs they can see, and respects the exact same access control Slack enforces for that person.

In production you resolve the identifier from your own auth system after you have verified the caller:

# Example: resolve identifier from your authenticated session user = get_authenticated_user(request) # your normal login / JWT / session logic identifier = user.scalekit_identifier # you stored this when they first connected # Now use it for every Scalekit call scoped_response, _ = actions.tools.list_scoped_tools( identifier=identifier, filter={"connection_names": ["slack"]}, ) # ... later ... result = actions.execute_tool( tool_name=block.name, identifier=identifier, tool_input=block.input, )

If the user has never connected Slack, get_or_create_connected_account returns a non-ACTIVE status and you surface the authorization link. Once they complete it, their token is vaulted and refreshed automatically. You never handle the token yourself.

The pattern is identical for Linear, Gmail, GitHub, or any other connector.

Troubleshooting

Here are the questions developers ask most often when wiring up their first Claude agent with Slack.

Why does the connection stay in PENDING_AUTH after the user authorizes?

For Slack, this almost always means the Scalekit Slack app has not been installed to the target workspace (or the user completed OAuth in a different workspace). The magic link still works, but Slack only shows workspaces where the app is already installed. In the Scalekit dashboard, go to the Slack connection and follow the install instructions, then have the user open a fresh authorization link.

Why does list_scoped_tools return zero tools?

The name you pass in the filter must exactly match the Connection name you created in the Scalekit dashboard (see the Critical note in the "Run it" section). Names are case-sensitive. An empty list is silent — no error is raised. This is the most common first-run failure.

Why do I get "not_in_channel", "channel_not_found", or permission errors?

The connected Slack identity does not have access to that channel or conversation. This is expected behavior — the agent can only see and act on what that specific user can see. Pass the exact error back to Claude as a tool_result (wrap the call in try/except and put the error message in the content field). Claude will usually adjust and ask for a different channel or tell the user what happened.

Why does Claude reply with text instead of calling any Slack tools?

The prompt was too vague. "Find recent messages about the Q3 launch in #product" works much better than "Tell me what's going on at work." Also confirm that llm_tools is populated before the first messages.create() call.

Why am I getting type or import errors with the Scalekit or Anthropic SDK?

Make sure you ran npm install @anthropic-ai/sdk @scalekit-sdk/node and that your tsconfig.json has appropriate settings for ESM (if using native ESM). The example uses top-level await, so running with npx tsx is the simplest way to execute it during development.

How do I handle rate limits or transient Slack errors?

Wrap the execute_tool call in a try/except, turn the exception into a short human-readable string, and return it as the tool result. Claude handles messages like "rate_limited" or "slack_error: ..." surprisingly well and will usually back off or rephrase on the next turn.

Explore more

Other connectors. The same 50-line pattern works with Gmail, Linear, GitHub, HubSpot, Notion, and dozens more. Just change the connection_name and the opening prompt. See the full connector catalog.

Other frameworks. The identical connected-account + listScopedTools / executeTool approach works with LangChain, CrewAI, Vercel AI SDK, Google ADK, and raw MCP servers. See all framework examples.

Source code. The runnable Anthropic + Scalekit examples live in the agent-auth-examples repo under python/frameworks/anthropic/ and javascript/frameworks/anthropic/.

No items found.
Agent Auth Quickstart
On this page
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