Skip to content
API Reference

Build a Slack Agent

Build an AI-powered Slack bot that uses MCP servers authenticated through Keycard

A Slack bot that just echoes commands is a toy. A Slack bot that can pull your GitHub PRs, check your calendar, and search your Drive — as you — is an agent. The difference is authentication: every API call must be scoped to the user who asked the question, not a shared service account.

Keycard handles that authentication. You connect MCP servers to Keycard, and each Slack user authorizes their own accounts through OAuth. When the agent needs to call an API, Keycard exchanges the user’s token for an API-specific credential — automatically, per-user, with full audit logging.

This guide builds an AI-powered Slack bot using the Keycard Python SDK, OpenAI Agents SDK, and Slack Bolt. The bot uses natural language — no slash commands — and requests authorization inline when it encounters a service the user hasn’t connected yet.

  • A Slack bot that responds to mentions and DMs with natural language
  • OpenAI agent that calls MCP tools on behalf of each user
  • Per-user OAuth via Keycard — auth links appear directly in the Slack thread
  • Persistent token storage so users only authorize once
  • Python 3.10+
  • Keycard account with a configured zone
  • Slack workspace where you can create apps
  • OpenAI API key
  • At least one deployed MCP server protected by Keycard (see Build an MCP Server and Third-Party Tools)
flowchart TD
    A[Slack User] --> B[Slack Bot<br/>Bolt + Starlette]
    B --> C[OpenAI Agent]
    C --> D[GitHub MCP]
    C --> E[Google MCP]
    C --> F[... MCP]
    D --> G[Keycard<br/>OAuth + Token Exchange]
    E --> G
    F --> G

The Slack bot receives a message, passes it to the OpenAI agent, and the agent decides which MCP tools to call. Each MCP server is authenticated through Keycard — the agent sees only the tools the user has authorized. If a service needs authorization, the agent sends an auth link directly in the Slack thread.

  1. Create a new Slack app

    1. Go to api.slack.com/apps
    2. Click Create New AppFrom scratch
    3. Name it (e.g., Keycard Agent) and select your workspace
    4. Click Create App
  2. Add bot scopes

    Navigate to OAuth & PermissionsBot Token Scopes:

    app_mentions:readRead messages mentioning the bot
    chat:writeSend messages
    im:historyRead DM history
    im:writeSend direct messages
  3. Enable Socket Mode

    Navigate to Socket Mode in the sidebar and toggle Enable Socket Mode to On. Generate an App-Level Token with the connections:write scope. Copy this token (starts with xapp-).

  4. Enable events

    Navigate to Event Subscriptions → toggle Enable Events to On:

    Subscribe to bot eventsapp_mention, message.im
  5. Install to workspace

    1. Navigate to Install AppInstall to Workspace
    2. Review permissions and click Allow
    3. Copy the Bot User OAuth Token (starts with xoxb-)
  6. Copy credentials

    Navigate to Basic Information and copy:

    • Signing Secret
  1. Ensure your MCP servers are registered

    Your MCP servers should already be registered as resources in Keycard. If not, follow the Build an MCP Server and Third-Party Tools guides.

  2. Create an Application for the Slack bot

    In Keycard Console, navigate to ApplicationsCreate Application:

    Application NameSlack Agent
    DependenciesSelect your MCP server resources (e.g., GitHub MCP, Google MCP)
  3. Generate client credentials

    On the application detail page, generate Client Credentials (Client ID + Client Secret). Save these — you’ll need them in the next step.

  4. Add the OAuth redirect URL

    On the application detail page, go to Redirect URLs and add your bot’s callback URL:

    • Local development: http://localhost:8080/oauth/callback
    • Production: https://your-bot.example.com/oauth/callback

    This must match the redirect_uri in agent.py. Keycard validates the redirect URI during OAuth — if it doesn’t match, the flow will fail.

  1. Create project directory

    Terminal window
    mkdir slack-agent && cd slack-agent
    python -m venv .venv && source .venv/bin/activate
  2. Install dependencies

    Terminal window
    pip install keycardai-mcp slack-bolt openai-agents python-dotenv uvicorn
  3. Create .env

    Terminal window
    # Slack
    SLACK_BOT_TOKEN=xoxb-your-bot-token
    SLACK_SIGNING_SECRET=your-signing-secret
    SLACK_APP_TOKEN=xapp-your-app-token
    # OpenAI
    OPENAI_API_KEY=sk-your-openai-key
    # Keycard
    KEYCARD_ZONE_URL=https://your-zone.keycard.cloud
    KEYCARD_CLIENT_ID=your-agent-client-id
    KEYCARD_CLIENT_SECRET=your-agent-client-secret
    # Keycard MCP Servers
    GITHUB_MCP_URL=https://your-github-mcp.example.com/mcp
    GOOGLE_MCP_URL=https://your-google-mcp.example.com/mcp
    # App (HTTP server for OAuth callback)
    PORT=8080
  4. Create project structure

    slack-agent/
    ├── .env
    └── src/
    └── slack_agent/
    ├── __init__.py
    ├── config.py
    ├── agent.py
    └── bot.py

Before looking at the code, here are the Keycard SDK classes that power the integration:

  • ClientManager — manages per-user MCP connections, keyed by slack:{user_id}
  • StarletteAuthCoordinator — handles OAuth callbacks at /oauth/callback
  • SQLiteBackend — persists tokens across restarts so users only authorize once
  • SlackAuthToolHandler — sends auth links directly in the user’s Slack thread
  • OpenAIAgentsClient — wraps everything for OpenAI Agents: get_system_prompt() adds auth awareness, get_mcp_servers() returns only authenticated servers, get_auth_tools() provides a request_authentication tool
import os
from dotenv import load_dotenv
load_dotenv()
SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
SLACK_APP_TOKEN = os.environ["SLACK_APP_TOKEN"]
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
KEYCARD_ZONE_URL = os.environ["KEYCARD_ZONE_URL"]
KEYCARD_CLIENT_ID = os.environ["KEYCARD_CLIENT_ID"]
KEYCARD_CLIENT_SECRET = os.environ["KEYCARD_CLIENT_SECRET"]
PORT = int(os.getenv("PORT", "3000"))
MCP_SERVERS = {}
if os.getenv("GITHUB_MCP_URL"):
MCP_SERVERS["github"] = {
"url": os.environ["GITHUB_MCP_URL"],
"transport": "streamable-http",
"auth": {"type": "oauth"},
}
if os.getenv("GOOGLE_MCP_URL"):
MCP_SERVERS["google"] = {
"url": os.environ["GOOGLE_MCP_URL"],
"transport": "streamable-http",
"auth": {"type": "oauth"},
}

This is the core of the bot. It uses the Keycard SDK to manage per-user MCP connections and the OpenAI Agents SDK to run the agent.

from agents import Agent, Runner
from slack_sdk.web.async_client import AsyncWebClient
from keycardai.mcp.client import ClientManager, SQLiteBackend, StarletteAuthCoordinator
from keycardai.mcp.client.integrations.auth_tools import SlackAuthToolHandler
from keycardai.mcp.client.integrations.openai_agents import OpenAIAgentsClient
from .config import PORT, MCP_SERVERS
storage = SQLiteBackend("tokens.db")
coordinator = StarletteAuthCoordinator(
backend=storage,
redirect_uri=f"http://localhost:{PORT}/oauth/callback",
)
client_manager = ClientManager(
servers=MCP_SERVERS,
storage_backend=storage,
auth_coordinator=coordinator,
)
AGENT_INSTRUCTIONS = (
"You are a helpful Slack assistant. Use your MCP tools to answer questions "
"about the user's GitHub repos, calendar, Drive files, and other connected services. "
"Be concise. Format responses for Slack using *bold*, _italic_, and bullet lists."
)
async def handle_message(
text: str, user_id: str, channel_id: str, thread_ts: str,
slack_client: AsyncWebClient,
) -> str:
"""Process a Slack message through the AI agent with MCP tools."""
mcp_client = await client_manager.get_client(context_id=f"slack:{user_id}")
auth_handler = SlackAuthToolHandler(
slack_client=slack_client,
channel_id=channel_id,
thread_ts=thread_ts,
)
async with OpenAIAgentsClient(mcp_client, auth_tool_handler=auth_handler) as openai_client:
agent = Agent(
name="slack_agent",
instructions=openai_client.get_system_prompt(AGENT_INSTRUCTIONS),
mcp_servers=openai_client.get_mcp_servers(),
tools=openai_client.get_auth_tools(),
)
result = await Runner.run(agent, text)
return result.final_output
import asyncio
import uvicorn
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
from slack_bolt.async_app import AsyncApp
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from .agent import coordinator, handle_message
from .config import PORT, SLACK_APP_TOKEN, SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET
app = AsyncApp(token=SLACK_BOT_TOKEN, signing_secret=SLACK_SIGNING_SECRET)
@app.event("app_mention")
async def handle_mention(event, say, client):
"""Respond when mentioned in a channel."""
thread_ts = event.get("thread_ts") or event["ts"]
await say(text="Looking into that...", thread_ts=thread_ts)
response = await handle_message(
text=event["text"], user_id=event["user"],
channel_id=event["channel"], thread_ts=thread_ts, slack_client=client,
)
await say(text=response, thread_ts=thread_ts)
@app.event("message")
async def handle_dm(event, say, client):
"""Respond to direct messages."""
if event.get("subtype") or event.get("bot_id"):
return
response = await handle_message(
text=event["text"], user_id=event["user"],
channel_id=event["channel"], thread_ts=event["ts"], slack_client=client,
)
await say(text=response, thread_ts=event["ts"])
starlette_app = Starlette(routes=[
Route("/oauth/callback", coordinator.get_completion_endpoint()),
Route("/health", lambda req: JSONResponse({"status": "ok"})),
])
async def main():
socket_handler = AsyncSocketModeHandler(app, SLACK_APP_TOKEN)
config = uvicorn.Config(starlette_app, host="0.0.0.0", port=PORT)
server = uvicorn.Server(config)
await asyncio.gather(socket_handler.start_async(), server.serve())
if __name__ == "__main__":
asyncio.run(main())

The bot uses Socket Mode for Slack events (no public URL needed) and runs a lightweight Starlette server for the Keycard OAuth callback.

  1. Start the bot

    Terminal window
    python -m slack_agent.bot
  2. Test in Slack

    1. Invite the bot to a channel: /invite @Keycard Agent
    2. Mention the bot: @Keycard Agent what are my open PRs on GitHub?
    3. The bot detects GitHub needs authorization and posts an auth link in the thread
    4. Click the link and complete the OAuth flow
    5. Ask again: @Keycard Agent what are my open PRs on GitHub?
    6. The bot calls the GitHub MCP server and returns your PRs
  3. Verify in Keycard Console

    Check Audit Logs for the authentication and token exchange events:

    users:authenticateThe Slack user authenticated via Keycard
    users:authorizeAccess was authorized to the MCP server
    credentials:issueAPI-specific token was issued via exchange

    Every tool call traces back to the specific Slack user — not the bot’s service account.

  1. User sends @Keycard Agent show my calendar for today in Slack
  2. bot.py receives the event and calls handle_message()
  3. agent.py gets (or creates) an MCP client for this user via ClientManager
  4. OpenAIAgentsClient connects to all configured MCP servers and detects auth status
  5. If Google MCP needs auth: the agent calls request_authentication(service="google")
  6. SlackAuthToolHandler posts an auth link directly in the Slack thread
  7. The user clicks the link and completes OAuth with Keycard
  8. The coordinator stores the token — on the next message, the agent has access

When deploying:

  • Update the redirect_uri in agent.py to your production URL (must be HTTPS)
  • Set environment variables securely in your hosting platform
  • Update Keycard resource identifiers if your MCP server URLs changed
  • Consider switching to HTTP mode for production — Socket Mode is ideal for development, but HTTP event delivery scales better with load balancers

Cause: Event subscriptions not configured or bot not in channel.

Solution:

  • Verify the Request URL is set and verified in Slack
  • Invite the bot to the channel: /invite @Keycard Agent
  • Confirm the bot has app_mentions:read scope

Cause: The OAuth callback endpoint is not reachable from the browser.

Solution:

  • Verify the bot is running and the /oauth/callback endpoint is reachable at http://localhost:{PORT}/oauth/callback
  • For production, update the redirect_uri in agent.py to your production URL

Cause: MCP server resource not listed as a dependency on the Application.

Solution:

  • In Keycard Console, check that your Application has the MCP server resources as Dependencies
  • Verify the MCP server is running and reachable

Cause: Application credentials misconfigured or missing dependency.

Solution:

  • Verify Client ID and Client Secret are correct
  • Check that the MCP server’s Application has the external API as a dependency
  • Review Audit Logs in Keycard Console for error details