
Integrations
Slack for AI Agents: User Tokens vs. Bot Tokens, and Shipping to Many Workspaces
When a Slack agent should post as its bot and when it should read as the user, how to request both in one install, and the 2025 rate-limit and API-terms changes that decide distribution.
TL;DR
An agent acting for a person in Slack should read with that person's user token and write with the app's bot token. Reading as the user means the agent sees exactly what the user can see. Posting as the bot means nobody mistakes the agent's words for theirs.
Slack's docs back both halves. User tokens carry "the same access a user has to a workspace", and search.messages accepts only a user token. Bot tokens survive the installing user leaving, which is why Slack calls them "usually for the best".
A single bot token isn't a shortcut for reading, because the bot only sees conversations it was added to, and a single installer's user token is worse, because it quietly gives every user that one admin's view.
Distribution decides more than tokens do, though. Since May 29, 2025, new installs of unlisted commercial apps get conversations.history and conversations.replies at 1 request per minute and 15 objects per call. If your agent reads history, plan for the Marketplace from day one.
Overview
Take a hypothetical customer-success platform. Each of its customers connects their own Slack workspace. An account manager at one of those customers asks the product's agent, "what did we tell Northwind about the renewal discount?", and later asks it to drop a summary in the #northwind-renewal channel. That's two jobs. One is a search across whatever that account manager can see. The other is a message the whole channel will read.
We already have a walkthrough of Slack's OAuth exchange and a first chat.postMessage in Python, over at How Slack's OAuth Flow Works. It covers the authorization-code dance for a single bot token in a single workspace. None of that is repeated here. This piece picks up where it stops: two token types with different identities, one install that yields both, hundreds of customer workspaces instead of one, and the 2025 platform changes that make "just ship an unlisted app" a worse plan than it was.
We'll carry the Northwind scenario through every section, because the right answers only fall out when you have a concrete agent doing concrete reads and writes. Abstract "bot or user?" debates tend to end in "it depends", and it mostly doesn't.
The key insight: in Slack the token type is the identity model. Choosing between xoxb and xoxp decides whose permissions the agent inherits and whose name appears on what it writes, so pick per action, not per app.
Two Identities, Not Two Flavors
Slack's token documentation is unusually direct about this. Bot tokens "represent a bot associated with an app installed in a workspace. Unlike user tokens, they're not tied to a user's identity—they're only tied to your app."
User tokens "represent workspace members" and "represent the same access a user has to a workspace — the channels, conversations, users, reactions, and so on that they can see." And the line that matters most for agents: "Write actions with user tokens are performed as if by the user themselves."
So a message posted with the account manager's xoxp- token shows up under her name and avatar. The chat.postMessage reference says the same from the other side: bot tokens post with the app's name and icon, user tokens post as the person who owns the token.
Visibility differs just as sharply. A bot is a participant. It reads the channels it has been invited into, and even posting outside them needs a separate scope, chat:write.public, which Slack describes as "Send messages to channels your Slack app isn't a member of". A user token sees what the person sees, private channels and DMs included if the matching scopes were granted.
Bot token (xoxb-) | User token (xoxp-) | |
|---|---|---|
| Represents | The app's bot user in one workspace | One workspace member |
| Scopes requested via | scope on the authorize URL | user_scope on the authorize URL |
| Can read | Conversations the bot belongs to (per granted scopes) | Whatever that member can see (per granted scopes) |
| Writes appear as | The app | The member, as if they typed it |
| Survives user deactivation | Yes | No, it's that user's credential |
search.messages | Not accepted | Accepted, with search:read |
| One per | Workspace install | Each user who authorizes |
Table 1 — Bot and user tokens compared, from Slack's token, method and scope references.
The last row is the one people underestimate. You get one bot token per workspace install. You get one user token per person who clicks through consent, and nobody else.
Which Token, When
Our position: read as the user, write as the bot, and only write as the user when the user explicitly asked for words to go out under their name.
Reading as the user is about permissions, not convenience. When the account manager asks about Northwind, the correct answer is bounded by what she could have found herself. A bot token can't produce that answer, because the bot only sees channels someone added it to. That's the wrong set in both directions: too narrow, since it misses her DMs and the private deal channel, and potentially too wide, since the bot might sit in a channel she isn't in. Hand the bot's reading to her and you've built a small privilege-escalation path into your product.
Search settles the argument anyway. search.messages lists only the user token type, with the search:read scope, at Tier 2 ("20+ per minute"). There is no bot-token variant. If the agent's job includes "find what we said about X", you need a user token, full stop.
Writing as the bot is about attribution. A digest in #northwind-renewal written by the agent should look like it was written by the agent. Posting it with her user token makes an LLM's summary indistinguishable from something she typed, in a channel where the customer's own staff read it as her statement. We think that's the wrong default even when the summary is accurate, and a real problem the first time it isn't. The bot identity also keeps working when she changes roles or leaves, which is the reason Slack itself gives for preferring bot tokens.
The exception is narrow. If she says "reply to Dana for me and say we can do 12%", the message is hers, she asked for it, and posting it with her token is correct. What we'd avoid is the agent deciding to speak as her.
Figure 1 — Token selection per action. The default for writes is the bot; the default for anything search-shaped is the requesting user's own token.
The anti-pattern to name plainly is the "installer token". The admin who installs your app often also grants user scopes, and oauth.v2.access hands you their xoxp- token in authed_user. It's tempting to use that token for every search in that workspace. Don't. It turns every end user's query into a query run with the admin's access, which is exactly the leak the user-token model exists to prevent.
One Install, Two Grants
Slack's OAuth v2 authorize URL takes two separate scope parameters. scope asks for bot scopes; user_scope asks for user scopes. Both can go in the same request, which is how the installing admin grants the bot and authorizes their own user token in one screen.
GET https://slack.com/oauth/v2/authorize
?client_id=1234567890.0987654321
&scope=chat:write,channels:read,channels:history
&user_scope=search:read,channels:history,groups:history
&redirect_uri=https://app.example.com/slack/callback
&state=8f3c2a...The response from oauth.v2.access carries both. The top-level access_token is the bot token with token_type: "bot"; the user grant sits in authed_user, with its own id, scope, access_token and token_type: "user". Every other person at the customer who wants the agent to search on their behalf goes through the same authorize URL themselves. The user-token doc says tokens are "issued for the user who installed the app and for users who authenticate the app", so each gets their own. For those later users you only need user_scope; the bot already exists.
Figure 2 — A combined install. One exchange yields the workspace's bot token and the installer's user token, which belong in different rows.
Scope requests deserve more care on the user side than the bot side. A bot with channels:history reads channels it was invited to. A user token with channels:history reads, per Slack's doc, conversations.history "for any public channel". Ask for groups:history or im:history on the user side only if the agent actually needs private channels or DMs, and expect a security-conscious customer admin to ask why.
Shipping to Many Workspaces
An app starts life installable into its own development workspace. To let customers install it, you go to Manage Distribution in the app settings, clear the checklist (HTTPS on every redirect and request URL, a working OAuth flow, no hard-coded workspace data) and press Activate Public Distribution. At that point it's an unlisted distributed app: installable anywhere through your link, not in the Marketplace, not reviewed.
Storage is where multi-workspace support tends to break. The bot token belongs to an installation, and on Enterprise Grid an installation isn't always a workspace. An org admin can install an org-ready app once across the whole organization. When that happens oauth.v2.access returns "is_enterprise_install": true, the team field is null, and the enterprise object carries the org's ID and name. Slack also notes that an org-wide install isn't added to any workspace automatically; the admin adds it to workspaces afterwards. Apps opt into this under Org Level Apps in settings, which sets org_deploy_enabled in the manifest.
So key installations on (enterprise_id, team_id) with a nullable team_id, and look up with a fallback: exact workspace first, then the org-wide row. User grants hang off the installation and are keyed by the Slack user ID and your own user ID.
from dataclasses import dataclass
@dataclass(frozen=True)
class InstallKey:
enterprise_id: str | None # set on Grid, org-wide or not
team_id: str | None # None when is_enterprise_install is true
def installation_key(resp: dict) -> InstallKey:
ent = (resp.get("enterprise") or {}).get("id")
if resp.get("is_enterprise_install"):
return InstallKey(enterprise_id=ent, team_id=None)
return InstallKey(enterprise_id=ent, team_id=resp["team"]["id"])
def find_bot_token(store, enterprise_id: str | None, team_id: str) -> str:
row = store.get(InstallKey(enterprise_id, team_id))
if row is None and enterprise_id:
row = store.get(InstallKey(enterprise_id, None)) # org-wide install
if row is None:
raise LookupError("no Slack installation for this workspace")
return row.bot_tokenThe user-grant row needs the Slack authed_user.id so you can tell whose token you're holding, and your own internal user ID so the agent can find it. That second key must come from your authenticated session, never from the callback query string. An agent that resolves "which Slack token do I use?" from anything the model or the browser supplied is one prompt injection away from searching as someone else.
Store two kinds of row, not one. Installations own bot tokens and are keyed by enterprise and team. User grants own user tokens and are keyed by your user and theirs. Collapsing them is how the installer's token ends up answering everyone's questions.
Token Rotation
By default Slack's tokens don't expire, which our earlier Slack post covers. Token rotation is the opt-in alternative. With it on, Slack's doc says the access token "expires every 12 hours", and oauth.v2.access returns an expires_in (43200 in Slack's examples) plus a refresh_token. You refresh with the same method and grant_type=refresh_token, which "refreshes an access token, whether bot or user". Rotation applies to "granular bot or user tokens", and oauth.v2.exchange converts an existing long-lived token into an expiring one plus a refresh token.
POST https://slack.com/api/oauth.v2.access
Content-Type: application/x-www-form-urlencoded
client_id=1234567890.0987654321&client_secret=...&grant_type=refresh_token&refresh_token=xoxe-1-...For a multi-workspace agent, rotation multiplies your state. You now have an expiring bot token per installation and an expiring user token per person, each with its own refresh token, and Slack's doc describes the old refresh token being revoked as a new one takes over. Two workers refreshing the same grant at the same moment is the failure we'd design against first. Take a per-grant lock, write the new pair atomically, and treat a failed refresh as "this user must reconnect" rather than retrying forever. Bolt handles rotation for you, but only if Bolt's installation store is the thing holding your tokens.
We'd turn rotation on for user tokens in any agent product. A leaked xoxp- token reads what a person reads, and a 12-hour window caps that exposure in a way a non-expiring token can't. The bot token matters less, since the bot sees less, but running one refresh path for both is simpler than running two.
Rate Limits, Distribution, and the API Terms
Slack rate-limits "per API method per workspace/team per app", which suits a multi-workspace product: one noisy customer can't starve another. Most methods sit in tiers. conversations.history and conversations.replies are Tier 3, "50+ per minute". search.messages is Tier 2, "20+ per minute". chat.postMessage has its own rule: it "will generally allow an app to post 1 message per second to a specific channel." Over the limit, you get HTTP 429 with a Retry-After header.
Then the 2025 change. On May 29, 2025 Slack announced that for non-Marketplace commercial apps, conversations.history and conversations.replies drop to "1 request per minute" with "a maximum of 15 objects per request". The conversations.replies reference puts it as: "The maximum and default values for the limit parameter have both been reduced to 15 objects." The limits applied immediately to new unlisted apps and to new installations of existing unlisted apps, while existing installations kept their old limits. A June 3 clarification added that "any internal customer-built apps will maintain their existing rate limits and will not be subject to the new posted limits."
Run the Northwind agent against that. Summarizing a busy renewal channel means paging through history and then pulling replies for each thread. At 15 messages per call and one call per minute, a channel with 300 messages across 40 threads needs roughly 20 history calls and at least 40 replies calls. Even running the two methods' budgets in parallel, that's 40 minutes or more of wall-clock time for one summary. For an interactive agent that's unusable. If you're building in 2026, every new customer is a new installation, so grandfathering doesn't help you.
Figure 3 — What each distribution path gets you for reading, after the May 2025 change and the February 2026 RTS general availability.
Slack's suggested alternative is the Real-time Search API, which it declared generally available alongside its MCP server on February 17, 2026. It's query-based rather than bulk, which is a better fit for an agent anyway. But the RTS docs say plainly that "The RTS API is available for directory-published apps and internal apps only." And "to fetch private conversation data or to use it outside the Slack client, a user token is required." So an unlisted app gets neither fast history nor RTS. The 2025 announcement names only history and replies, so search.messages with a per-user token still works at Tier 2, and for an unlisted agent it's the realistic read path.
The Marketplace isn't a formality. Slack's guidelines treat apps that "are installed on less than 10 active workspaces and have less than 10 weekly active users" as not ready to list, so you'll ship unlisted first regardless. Reviewers also expect a justification for every scope, and on user tokens the guideline is specific: "Use of user token scopes should only be used when your app is acting as your authenticating user." Our read-as-user, write-as-bot split lines up with that. Asking for chat:write as a user token so the agent can post under people's names does not. Generative-AI apps must also "add a disclaimer to your landing page and long description to let users know of the app's potential to generate inaccurate responses, summaries or other outputs if it uses a LLM", and disclose the model and data handling.
The API terms apply whether or not you list. The current Slack API Terms of Service (effective October 10, 2025) say that as the provider of an application used outside your organization, "you may not: (A) use API Data to train a large language model; (B) bulk export Slack message and file data except where expressly allowed by an additional agreement". Retention is bounded too: you "must limit your use, processing, and retention of API Data from others outside your organization to the minimum necessary". For the Data Access and RTS APIs specifically, "you may not create persistent copies, archives, indexes, or long-term data stores of other organizations' API Data."
That last clause closes the obvious workaround to the rate limits. Indexing every customer's Slack into your own vector store so the agent can search it fast is the architecture the terms were written against. Fetch at query time, keep what the current answer needs, and drop it.
Distribution is an architecture decision. An unlisted app that reads history will hit 1 request per minute on every new install. If reading Slack is core to the agent, the Marketplace review is on your critical path, and your scopes and token split should be written to pass it from the first commit.
Conclusion
The Northwind agent ends up with a fairly specific shape. One bot token per installation, stored against enterprise and team, used for everything it posts. One user token per person who connects, used for search and for anything whose answer should be bounded by what that person can see. User-authored writes only on explicit request. Rotation on, refreshes serialized per grant. Retrieval at query time rather than a private copy of the customer's Slack. And a plan to get listed, because the unlisted path caps history reads hard enough to break an interactive agent.
The part we'd push hardest on is the per-user grant. Almost every shortcut here, the installer token, the bot reading everything, the shared index, trades away the property that makes an agent safe to point at a company's Slack: it only knows what the person asking is allowed to know.
If you'd rather not run the per-user grant store yourself, this is the problem Fabriq Developer is built for. Each of your end users is an external user id you choose, they connect their own Slack account (provider slug slack) through an AF-hosted consent flow, and the credential lands in Fabriq's vault rather than on your servers. Your agent then mints a short-lived token for that one user and calls tools over MCP at /mcp/external. Which providers are connectable on a given deployment is exposed by the capabilities endpoint rather than hard-coded:
GET /api/v1/apps/{app_id}/external-users/providers/capabilities
POST /api/v1/apps/{app_id}/external-users/{uid}/oauth/{provider}/initiate
POST /mcp/externalThe rule to keep: the agent reads with the asking user's token and speaks with its own. Every Slack design choice above follows from refusing to blur those two identities.
Sources
- Tokens — Slack Developer Docs; bot vs. user token identity and access.
- Installing with OAuth — Slack Developer Docs;
scopevs.user_scope,authed_userin the response. - oauth.v2.access — Slack method reference; response fields,
is_enterprise_install, refresh grant. - search.messages — Slack method reference; user token only,
search:read, Tier 2. - chat.postMessage — Slack method reference; posting identity and the per-channel rate limit.
- conversations.replies — Slack method reference; Tier 3 and the non-Marketplace limit.
- Rate limit changes for non-Marketplace apps — Slack changelog, May 29, 2025.
- Rate limits — Slack Developer Docs; per-method, per-workspace, per-app evaluation.
- Using token rotation — Slack Developer Docs; 12-hour expiry, refreshable token types.
- Developing for Enterprise organizations — Slack Developer Docs; org-wide installs,
team: null,org_deploy_enabled. - App distribution — Slack Developer Docs; activating public distribution, unlisted vs. Marketplace.
- Slack Marketplace app guidelines and requirements — install thresholds, user-token and LLM requirements.
- Slack's MCP server and Real-Time Search API now available — Slack, February 17, 2026; general availability.
- Using the Real-time Search API — Slack Developer Docs; availability and user-token requirement.
- Slack API Terms of Service — effective October 10, 2025; LLM training, bulk export and retention clauses.