Build a Slack agent
Build a Slack bot that answers questions from each user's Google Calendar through a Keycard-protected MCP server, with per-user authorization instead of a shared service-account token.
In this tutorial, you’ll build a Slack bot that answers questions like “what’s on my calendar today?” from each user’s own Google Calendar. The agent connects to a Keycard-protected MCP server; each Slack user authorizes once, and every downstream call runs as that user with a short-lived, scoped credential.
Most Slack agents authenticate to their downstream tools with a single shared service-account token, then try to re-implement each user’s permissions in the application layer. That mirror is expensive to build, easy to get wrong, and drifts the moment the upstream tool’s permissions change. Here, Keycard removes the mirror: the upstream system enforces each user’s real permissions, there are no long-lived downstream secrets, and every authorization is logged.
Architecture
Section titled “Architecture”There are two authorization hand-offs, and Keycard sits under both:
- User to MCP server: the agent is an MCP client. On first use, each Slack user authorizes through the standard MCP authorization flow (OAuth 2.0 with PKCE) against Keycard, and the agent holds a per-user MCP session. The agent needs no Keycard secret for this; it registers as a public client.
- MCP server to Google: the MCP server authenticates to Keycard with its own application credential (client ID + secret to start) and exchanges each incoming user token for a Google API token scoped to that user. The agent never sees a Google credential.
What you’ll build
Section titled “What you’ll build”By the end of this tutorial, you’ll have:
- Google connected to Keycard as a credential provider
- A Keycard-protected Google MCP server running locally with Calendar tools
- A Slack bot (in Python or TypeScript, your pick) that answers calendar questions as the requesting user
- An audit trail in Keycard Console tracing every access to the specific Slack user
Prerequisites
Section titled “Prerequisites”Before starting, you’ll need:
- A Keycard account. Your Keycard URL and Redirect URL are under Configuration → Connection (you’ll need them throughout)
- A Slack workspace where you can create and install apps
- A Google Cloud account with permission to create OAuth clients
- An Anthropic API key (the agent’s LLM loop uses Claude)
- uv installed (or Docker, both repos ship a Dockerfile)
- Clone the tutorial repository (it contains both services):
Terminal window git clone https://github.com/keycardai/tutorial-slack-agent.gitcd tutorial-slack-agent
Part 1: Connect Google to Keycard
Section titled “Part 1: Connect Google to Keycard”First, create a Google OAuth client and register it with Keycard so Keycard can broker Google credentials for your users.
-
Copy your Keycard redirect URL
In Keycard Console, copy the Redirect URL from Configuration → Connection tab → OAuth.
-
Create a Google OAuth client
In Google Cloud Console, open APIs & Services → Credentials (the APIs & Services section is under the ☰ menu in the top-left). Create an OAuth 2.0 Client ID (application type: Web application). Add the Keycard redirect URL from step 1 to Authorized redirect URIs, then note the Client ID and Client Secret.
Also enable the Calendar API: APIs & Services → Enabled APIs & services → + Enable APIs and services, search Google Calendar API, open it, and click Enable.
-
Add Google Calendar from the Keycard catalog
In Keycard Console, open Resources in the sidebar, click the dropdown arrow on the Add Resource button, and choose Explore Resources. Search for Google Calendar, select it, and fill in the Google Client ID and Client Secret from step 2. This creates the Google OAuth provider and the Google Calendar API resource (identifier
https://www.googleapis.com/calendar/v3). -
Confirm the Calendar scope on the resource
Open the Google Calendar resource and check its Scopes. Reading a calendar needs
https://www.googleapis.com/auth/calendar.readonly; if you want the create/update/delete tools too, usehttps://www.googleapis.com/auth/calendar. Without a scope the token exchange returns a credential Google rejects, so add it here if the catalog did not.
Part 2: Register the MCP server in Keycard
Section titled “Part 2: Register the MCP server in Keycard”Now register the MCP server itself. It shows up in Keycard as two linked things, and it helps to know why:
- a resource, the thing users authorize and get a token for (the MCP endpoint), and
- an application, the identity the server uses to call other resources on a user’s behalf (here, Google).
You register both and link them on one application: the application provides the MCP resource and depends on the Google resource. That pairing is what lets the server exchange a user’s MCP token for a Google token, the two hand-offs in the diagram below.
flowchart LR
user([Slack user])
subgraph app [Application: Google MCP Server]
mcpres[Provides: Google MCP resource<br/>http://localhost:8000/mcp]
dep[Depends on: Google Calendar<br/>https://www.googleapis.com/calendar/v3]
end
google([Google Calendar API])
user -->|1. authorizes and gets a token for| mcpres
mcpres -.->|2. token exchange| dep
dep -->|3. scoped Google token| google
-
Create the Google MCP resource
Navigate to Resources. Click the dropdown arrow on the Add Resource button and choose Add Manually.
Field Value Resource Name Google MCP Server Resource Identifier http://localhost:8000/mcpCredential Provider Zone Provider -
Create the Google MCP application
Navigate to Applications and click Add Application (this button goes straight to the form, no dropdown).
Field Value Name Google MCP Server Identifier http://localhost:8000/mcpClick Create Application. You land on the application’s details page, on the Dependencies tab.
- On the Dependencies tab, click Add dependency and select the Google Calendar API resource.
- On the Provides tab, click Add provided resource and select Google MCP Server.
-
Generate client credentials
On the application details page, go to Application Credentials and click Add Credential, selecting Client ID & Secret.
-
Confirm access policy
Navigate to Policies and make sure an active policy set includes default-app-delegation (the preconfigured policy that lets applications exchange tokens on behalf of users) and permits your users to access the two resources. If you have no active policy set yet, create one following the pattern in Access Policies.
Part 3: Run the Google MCP server
Section titled “Part 3: Run the Google MCP server”In the google-mcp-server/ directory of the tutorial repository:
-
Configure the environment
Terminal window cd google-mcp-servercp .env.example .envFill in:
Terminal window KEYCARD_ISSUER=https://<keycard-id>.keycard.cloudKEYCARD_CLIENT_ID=<client-id-from-part-2>KEYCARD_CLIENT_SECRET=<client-secret-from-part-2>MCP_SERVER_URL=http://localhost:8000/GOOGLE_API_RESOURCE=https://www.googleapis.com/calendar/v3Leave
MCP_SERVER_URLas the base URLhttp://localhost:8000/: the server appendsmcpitself, so it advertises thehttp://localhost:8000/mcpresource you registered in Part 2. Trailing slashes are significant in these identifiers; copy them exactly as written. -
Start the server
Terminal window uv syncuv run python -m google_mcp_serverThe MCP endpoint is now at
http://localhost:8000/mcp.
Part 4: Create the Slack app and run the agent
Section titled “Part 4: Create the Slack app and run the agent”The repository ships the same agent twice: agent-python/ builds on the Keycard MCP client (keycardai-mcp), and agent-typescript/ builds on the official MCP SDK wired through @keycardai/mcp’s OAuth client provider. Pick the language you’d build in; everything else in the tutorial is identical. In a second terminal:
-
Create the Slack app from the manifest
Go to api.slack.com/apps → Create New App → From a manifest, pick your workspace, and paste the contents of
manifest.jsonfrom the agent directory you chose (agent-python/manifest.jsonoragent-typescript/manifest.json; the two are identical). The manifest enables Socket Mode (no public URL needed) with the bot scopes and events the agent uses.The manifest names the app Keycard Tutorial Agent. Rename it under
display_information.name(andfeatures.bot_user.display_name) before creating the app if you want a different name in your workspace, or if that name is already taken.After creating the app:
- Under Install App, install it to your workspace and copy the Bot User OAuth Token (
xoxb-...) - Under Basic Information → App-Level Tokens, generate a token with the
connections:writescope and copy it (xapp-...)
- Under Install App, install it to your workspace and copy the Bot User OAuth Token (
-
Configure the environment
Terminal window cd agent-pythoncp .env.example .envTerminal window cd agent-typescriptcp .env.example .envFill in:
Terminal window SLACK_BOT_TOKEN=<xoxb-token>SLACK_APP_TOKEN=<xapp-token>ANTHROPIC_API_KEY=<anthropic-api-key>MCP_SERVERS=[{"key":"google","url":"http://localhost:8000/mcp"}]The agent needs no Keycard credential: it registers with Keycard as a public MCP client via OAuth Dynamic Client Registration, and each user completes their own PKCE authorization.
-
Start the agent
Terminal window uv syncuv run slack-agentTerminal window npm installnpm run devThe agent connects to Slack over Socket Mode and serves the OAuth callback on
http://localhost:3000for the authorization flow. Run only one of the two variants (agent-pythonoragent-typescript) at a time: they connect to the same Slack app, so running both would answer every message twice.
Part 5: Test it in Slack
Section titled “Part 5: Test it in Slack”-
Ask for your calendar
In Slack, DM the bot (or mention it in a channel it’s in):
What's on my calendar today?Since you haven’t authorized yet, the bot replies with an authorization link.
-
Authorize once
Click the link. You’ll sign in to Keycard, then consent to the Google Calendar access (Google’s consent screen appears the first time, establishing trust between Keycard and Google). When the flow completes, the bot confirms the connection.
-
Ask again
What's on my calendar today?This time the agent lists your events. Each tool call carries a token scoped to you: another Slack user who asks gets their calendar after authorizing, and a user who never authorizes gets nothing.
-
Verify in Keycard Console
Open Audit Logs in Console. You should see entries for:
users:authenticateThe Slack user authenticated via Keycard users:authorizeAccess was authorized to the resource credentials:issueA scoped Google token was issued via exchange Every entry traces to the specific user, not the agent’s service identity. That trace is the accountability artifact: proof that each action was authorized as the right person.
Beyond the tutorial
Section titled “Beyond the tutorial”Calling provider APIs directly
Section titled “Calling provider APIs directly”The MCP server pattern above is one integration mode. If your agent calls a provider API directly (no MCP server in between), exchange the requesting user’s Keycard access token (RFC 8693 token exchange) for a credential scoped to the target resource, and inject it into the call:
# pip install keycardai-oauthimport os
from keycardai.oauth import AsyncClient, BasicAuth
async def token_for(user_access_token: str, resource: str) -> str: """Exchange the requesting user's Keycard token for a downstream token.""" async with AsyncClient( os.environ["KEYCARD_ISSUER"], auth=BasicAuth( os.environ["KEYCARD_CLIENT_ID"], os.environ["KEYCARD_CLIENT_SECRET"], ), ) as client: resp = await client.exchange_token( subject_token=user_access_token, subject_token_type="urn:ietf:params:oauth:token-type:access_token", resource=resource, ) return resp.access_token// npm install @keycardai/oauthimport { TokenExchangeClient, TokenType } from "@keycardai/oauth";
const keycard = new TokenExchangeClient(process.env.KEYCARD_ISSUER!, { clientId: process.env.KEYCARD_CLIENT_ID!, clientSecret: process.env.KEYCARD_CLIENT_SECRET!,});
async function tokenFor(userAccessToken: string, resource: string): Promise<string> { const resp = await keycard.exchangeToken({ subjectToken: userAccessToken, subjectTokenType: TokenType.ACCESS_TOKEN, resource, }); return resp.accessToken;}// go get github.com/keycardai/credentials-goimport ( "context" "os"
"github.com/keycardai/credentials-go/oauth")
var keycard = oauth.NewTokenExchangeClient( os.Getenv("KEYCARD_ISSUER"), oauth.WithClientCredentials( os.Getenv("KEYCARD_CLIENT_ID"), os.Getenv("KEYCARD_CLIENT_SECRET"), ),)
// tokenFor exchanges the requesting user's Keycard token for a downstream token.func tokenFor(ctx context.Context, userAccessToken, resource string) (string, error) { resp, err := keycard.ExchangeToken(ctx, oauth.TokenExchangeRequest{ SubjectToken: userAccessToken, SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token", Resource: resource, }) if err != nil { return "", err } return resp.AccessToken, nil}Acting as a user you resolved out of band
Section titled “Acting as a user you resolved out of band”Sometimes you’ve already mapped the Slack user to a Keycard user (for example via a verified email link) and want to act as them without a live user token in hand. The substitute-user exchange covers this: you assert the user identifier and Keycard issues a scoped credential for that user.
# AsyncClient.impersonate wraps the substitute-user exchange:resp = await client.impersonate( user_identifier=keycard_user_id, resource="https://www.googleapis.com/calendar/v3",)// TokenExchangeClient.impersonate wraps the substitute-user exchange:const resp = await keycard.impersonate({ userIdentifier: keycardUserId, resource: "https://www.googleapis.com/calendar/v3",});// TokenExchangeClient.Impersonate wraps the substitute-user exchange:resp, err := keycard.Impersonate(ctx, oauth.ImpersonateRequest{ UserIdentifier: keycardUserID, Resource: "https://www.googleapis.com/calendar/v3",})Bring your own harness
Section titled “Bring your own harness”Keycard does not prescribe how you build the agent. The tutorial repo uses Bolt in Socket Mode with a small Claude tool loop, but any reasonable choice works:
- The LLM loop: Claude Agent SDK, OpenAI Agents, or a hand-rolled tool-use loop. Keycard sits underneath, at the point each tool call needs a credential.
- Slack ingress: Socket Mode is the quickest start; HTTP event delivery scales better behind a load balancer.
- Approval / human-in-the-loop: gating risky writes behind a confirmation is an application concern. Keycard’s policies are the hard enforcement floor; an approval gate is a softer layer you add on top.
Next steps
Section titled “Next steps”You now have a Slack agent whose every downstream call is authorized as the requesting user. From here, you can:
- Switch the MCP server to a workload identity to eliminate its client secret
- Add more Keycard-protected MCP servers to
MCP_SERVERS(each gets its own per-user authorization) - Broaden the Google resource (Drive, Gmail, Docs, Sheets tools ship in the server) by registering the matching API resources
- Deploy both services anywhere that runs a container; update
MCP_SERVER_URL, the resource identifiers, and the agent’sBASE_URLto the public URLs - Set up audit log export to stream the authorization trail into your SIEM