# Build a Slack Agent

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.

## What You Will Build

- A Slack bot that responds to mentions and DMs with natural language
- OpenAI GPT-4o 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

## Prerequisites

- 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

## Architecture

```
Slack user ──▶ Slack Bot (Bolt + Starlette)
                  │
                  ▼
             OpenAI Agent (GPT-4o)
                  │
        ┌─────────┼─────────┐
        ▼         ▼         ▼
   GitHub MCP  Google MCP  ... MCP
        │         │         │
        └─────────┼─────────┘
                  ▼
         Keycard (per-user OAuth
          + token exchange)
```

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.

## Create Slack App

1. Create a new Slack app

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

2. Add bot scopes

   Navigate to OAuth & Permissions → Bot Token Scopes:

   | Scope | Description |
   | --- | --- |
   | app_mentions:read | Read messages mentioning the bot |
   | chat:write | Send messages |
   | im:history | Read DM history |
   | im:write | Send 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:

   | Setting | Value |
   | --- | --- |
   | Subscribe to bot events | app_mention, message.im |

   Note: With Socket Mode enabled, Slack delivers events over a WebSocket connection — no public URL or Request URL is needed.

5. Install to workspace

   1. Navigate to Install App → Install 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

## Configure Keycard

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 Applications → Create Application:

   | Field | Value |
   | --- | --- |
   | Application Name | Slack Agent |
   | Dependencies | Select 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.

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.

## Set Up the Project

1. Create project directory

   ```bash
   mkdir slack-agent && cd slack-agent
   python -m venv .venv && source .venv/bin/activate
   ```

2. Install dependencies

   ```bash
   pip install keycardai-mcp slack-bolt openai-agents python-dotenv uvicorn
   ```

3. Create `.env`

   ```bash
   # 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
   ```

## Write the Code

### `config.py` — Configuration

```python
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 protected by Keycard.
# Each entry maps a name to a server with OAuth authentication.
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"},
    }
```

Note: MCP server URLs are configured as environment variables. Future versions of Keycard will support automatic discovery from your application's dependency graph, eliminating the need for these variables.

### `agent.py` — AI Agent with MCP Tools

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.

```python
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

# Shared infrastructure — created once, used across all users
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."""

    # Get or create an MCP client for this Slack user
    mcp_client = await client_manager.get_client(context_id=f"slack:{user_id}")

    # Auth handler sends OAuth links directly to the user's Slack thread
    auth_handler = SlackAuthToolHandler(
        slack_client=slack_client,
        channel_id=channel_id,
        thread_ts=thread_ts,
    )

    # Connect to MCP servers + detect auth status
    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
```

Key SDK classes:
- `ClientManager` manages per-user MCP connections, keyed by `slack:{user_id}`
- `StarletteAuthCoordinator` handles OAuth callbacks at `/oauth/callback`
- `SQLiteBackend` persists tokens across restarts — 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, and `get_auth_tools()` provides a `request_authentication` tool the agent can call

### `bot.py` — Slack Bot Server

```python
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

# Slack Bolt app (Socket Mode — no public URL needed for events)
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 — serves the OAuth callback for Keycard auth
async def health(request):
    return JSONResponse({"status": "ok"})


starlette_app = Starlette(
    routes=[
        Route("/oauth/callback", coordinator.get_completion_endpoint()),
        Route("/health", health),
    ]
)


async def main():
    # Run Socket Mode handler and OAuth callback server concurrently
    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. When a user completes OAuth in their browser, the redirect hits localhost:{PORT}/oauth/callback, and the coordinator stores the token automatically.

## Run and Test

1. Start the bot

   ```bash
   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:

   | Event | Description |
   | --- | --- |
   | `users:authenticate` | The Slack user authenticated via Keycard |
   | `users:authorize` | Access was authorized to the MCP server |
   | `credentials:issue` | API-specific token was issued via exchange |

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

## How It Works

Full flow when a user asks the bot a question:

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: get_system_prompt() adds auth instructions, get_auth_tools() adds a request_authentication tool
6. The OpenAI agent processes the message and determines it needs Google Calendar access
7. The agent calls request_authentication(service="google", reason="To access your calendar")
8. SlackAuthToolHandler posts an auth link directly in the Slack thread
9. The user clicks the link, completes OAuth with Keycard in their browser
10. The browser redirects to /oauth/callback — the coordinator stores the token in SQLite
11. On the next message, OpenAIAgentsClient sees Google as authenticated, includes it in get_mcp_servers(), and the agent calls the calendar tools

## Deploy to Production

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

Tip: For infrastructure-as-code deployments, use the Keycard Terraform Provider (https://registry.terraform.io/providers/keycardai/keycard/latest/docs).

## Troubleshooting

### Bot Not Responding
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

### Auth Link Not Working
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

### MCP Tools Not Appearing After Auth
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

### Token Exchange Fails
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
