Back to Blog Home

How to Run Slack AI Agents on Per-User Permissions Instead of a Shared Bot Token

Folder access dialog listing named teammates with per-person edit and owner roles

Run the agent's privileged reads and writes on the requester's own grants, and let it post under its own name. Authority and authorship are separate decisions, and the mistake to avoid is one shared token that settles both. Whose permissions a tool call runs under decides what the agent can see and change. Which Slack identity posts the reply only decides what people see in the channel. If every call rides one xoxb- bot token, the only honest answer to "who authorized this write?" is "the bot."

Why shared bot tokens break multi-user agents

Slack's token model separates identities. Slack Developer Docs on Tokens describe bot tokens, which belong to the app, and user tokens, which represent workspace members and let an app act on their behalf. Slack Developers' guide to bot and user tokens draws the same line: messages posted with a bot token appear under the app's name, while a user token makes Slack treat the action as if that person performed it. Bot tokens also stay valid when any one person's account changes, which is why Slack calls them the right choice for most functionality. That durability is useful for app features and dangerous when every agent tool call rides the same broad bot scopes.

Many agent tutorials wire that bot identity first. One install, one token, every teammate mentions the same app. The model then reads channels the bot can see, posts as the bot, and opens connected systems with whatever shared credential the integration stored. When a second person shows up, authorship and access no longer match reality.

freeCodeCamp's handbook on per-user OAuth for AI agents describes the failure in production terms: every GitHub issue says the bot opened it, every Slack reply comes from the bot, and nobody can tell who an agent filed something for. It also flags a one-field trap in Slack's OAuth response. The top-level access_token is the bot, and the person who consented is in authed_user.access_token. Storing the wrong one silently restores shared-bot behavior. People ask this question in the open too, for example in a r/Slack thread on building a shared bot.

Security guidance often goes the other way. NHIMG's FAQ on Slack-connected AI agents as identities argues that webhooks, bot tokens, and MCP-backed apps should be treated as non-human identities with ownership, scope, and revocation, and its companion FAQ on Slack-native AI workflows recommends an execution identity that is separate from the human requester. That fits unattended jobs, where there is no requester to inherit from. For an interactive ask we recommend the opposite: run on the requester's grants, so the agent's reach can never exceed the asker's, and give schedules and other unattended work their own named identity.

Separate the three principals on every run

Define the identity model before you pick scopes. Every agent run has at least three principals:

  1. Requester: the Slack user who mentioned the agent or owns the scheduled job.
  2. Acting principal: the token identity that Slack and connected APIs will actually see (xoxb- bot or xoxp- user, plus any downstream OAuth principal such as GitHub).
  3. App identity: the Slack app record, install, and admin policy surface.

Interactive mentions should set the acting principal from the requester's grants for user-scoped reads and writes. Unattended schedules need an explicit principal named in the schedule contract, not "whoever last talked to the agent." The guide to scheduling recurring agent jobs covers that case. This guide stays with interactive multi-user work and any tool call that must inherit a person's grants.

Take the requester from the verified event, never from the model. The requester is an input to the tool call, not an output of it. Read the Slack user ID from the signed event (or the schedule record) and have the gateway look up the grants for that ID. If a prompt, a tool argument, or a message can name the user a call runs as, then "run this as Alice" is a privilege escalation. freeCodeCamp makes the same rule for its identifier: resolve it on the server from an authenticated session, and never accept it from a request body.

Slack's Governance and trust guide tells builders to log which channels were accessed, what actions were taken, who triggered the request, and what model was used. That baseline only works if your runtime knows the requester and the acting principal as separate fields. Logging "bot did something" is not an audit trail.

Kipwise Agent fits this model as a thread-native work agent. A teammate can mention it once and continue with ordinary replies. Applicable actions use the requesting user's permissions. It cites sources it opened, refuses to guess, and keeps consequential writes explicit.

Choose bot token or user token per tool call

Avoid one token type for the whole agent. Choose per capability.

Use a bot token when the app should speak as itself: status notifications, "I started the run" acknowledgements, public summaries that must not impersonate a person, or events that depend on channels the bot was invited to. Bot scopes stay durable when people leave.

Use a user token when the action must inherit a person's visibility or authorship: reading conversations the bot was never invited to, posting as the person, opening private knowledge the person can see, or writing to a connected system where audit expects a human principal. Slack's own guidance is to request user tokens only when a feature genuinely requires them.

A practical decision rule for Slack AI agents:

  • If the tool only needs app-visible public context and should post as the app, prefer a bot token.
  • If the tool reads or writes anything the requester might not be allowed to see or change, require a user token (or an explicit delegated service role with its own review).
  • If the tool creates an artifact in another system (issue, ticket, doc edit), default to the requester's OAuth grant for that provider.
  • If you cannot name the acting principal before the call, refuse the call.

The freeCodeCamp handbook shows the Slack parameter split in one line: scope requests bot permissions, user_scope requests user permissions. An agent that needs per-user behavior must request and store the user grant on purpose. Hoping the install "just works" with only bot scopes will not produce requester-bound reads.

Keep per-user grants behind a gateway, away from the model

Per-user OAuth adds a storage requirement. Each user consents once, and your system must keep those grants encrypted, keyed by user and provider, refreshable, and revocable. The model should never see the raw token.

  1. User completes OAuth for Slack (and separately for GitHub, Google, or helpdesk tools as needed).
  2. Store provider, user_id, encrypted refresh material, granted scopes, and expiry.
  3. At tool time, resolve requester_id to a short-lived access token inside a gateway.
  4. Pass the model an opaque principal identifier such as slack_user:U123, never xoxp-....
  5. Execute the HTTP call in the gateway. Discard the access token from memory after the call.
  6. On revoke or offboarding, delete or freeze that user's grant without uninstalling the whole app.

Also record which token you intended. Teams that keep one "Slack token" field will eventually load a bot token into a user-scoped code path. There is a cheap canary for it. A user token returns only the channels that person can already see, so if conversations.history returns a channel the current user was never in, a bot token is in the store. Keep secrets out of prompts, tool schemas, traces, and support logs so a model transcript cannot leak workspace credentials.

Check grants again at tool time

Consent at install time does not replace authorization at run time. Role changes, channel membership changes, and revoked connector grants are common. Before each consequential tool call:

  1. Resolve the requester from the Slack event or schedule contract.
  2. Load the grant record for that user and provider.
  3. Confirm the grant is present, unexpired, and includes the scopes the tool needs.
  4. Confirm any resource allowlist (channels, repos, knowledge spaces) still matches.
  5. Deny with a clear error the requester and admins can understand.
  6. Only then mint or reuse a short-lived access token and execute.

Treat denial as a first-class outcome. If the requester cannot see the private incident channel, the agent should stop, say it lacks access, and avoid summarizing neighboring public content as if it were the requested source. Quietly falling back to the shared bot token recreates the original overreach.

For writes, combine identity checks with your approval gates. Per-user permissions answer "whose grants?" Approval answers "should this write proceed now?" High-impact actions usually need both, and the guide to human approval for AI agent workflows in Slack covers the second. Slack's governance guide also asks builders to give admins modular capability controls so teams can start narrow and expand. Map those admin switches onto tool allowlists in your gateway, not onto prompt text that a model can ignore.

Build an audit event that security can read

If every action posts as the bot, Enterprise audit and your own logs still need a requester field you control. Minimum fields for each agent tool event:

  • timestamp
  • requester_user_id and display name snapshot
  • acting_principal_type (bot or user) and principal id
  • app_id / agent_id
  • thread_ts / channel_id when applicable
  • tool_name and normalized arguments (redact secrets)
  • scopes_used and provider
  • decision (allowed, denied, approved_write, failed)
  • outcome and error class
  • model / run id for cost and regression joins

Slack's governance guidance already lists user ID, agent ID, tools called, model, and outcome among the metrics to track for each response. Extend that list with the acting principal and scopes used, so a shared bot notification and a user-scoped write never look identical in storage. Make requester-attributed audit events a release gate for the agent, not a later SIEM wish list.

Worked example: support answer with knowledge and ticket write

A support lead in #customer-help mentions the agent and asks it to draft an answer from the help center and open a follow-up ticket if the runbook says so.

Shared-bot failure path:

  1. Bot token reads every channel the app was invited to, including unrelated private triage channels.
  2. Draft cites a page the lead cannot open in the help center ACL.
  3. Ticket opens under the integration service account.
  4. Audit shows the app wrote the ticket. Security cannot map it to the lead without reading Slack history.

Per-user control path:

  1. Event identifies the support lead as requester.
  2. Gateway loads the lead's Slack user grant and helpdesk OAuth grant.
  3. Knowledge read uses the lead's visibility. Missing access returns a denial the lead can escalate.
  4. Draft cites only opened sources.
  5. Ticket write uses the lead's helpdesk principal, or pauses for approval if your write policy requires it.
  6. Audit stores requester, user principal, scopes, sources opened, and ticket id.

The Slack reply can still use a bot token for the app-authored summary if you want the agent branded in-channel, while the privileged reads and external write stay on user grants. Mixed token use is fine. Mixed attribution without a schema is not.

Failure modes to design before rollout

  • Missing user grant: requester never completed OAuth. Prompt a connect link; do not fall back to the bot token for user-scoped tools.
  • Wrong token stored: bot token saved where the user token belongs. Detect it with the canary read above.
  • Stale grant after role change: the tool-time check fails; notify the requester and a security contact.
  • User deactivated: the user-token path breaks by design, which is correct for interactive work. Scheduled jobs need a named service principal reviewed separately.
  • Over-scoped consent: the user granted broad scopes the agent does not need. Prefer incremental scope requests and admin policy to disable unused tools.
  • Model asks for a disallowed tool: the gateway denies it and logs the attempt. Do not "helpfully" retry with the shared bot.
  • Cross-user confusion in a shared thread: key grants by the message author (or an explicit delegate), never by the last bot reply, and key the thread by its parent message. Slack's chat.postMessage docs say to pass the parent's timestamp as thread_ts and to avoid using a reply's.

Your revoke path should disable one user's grants in minutes without uninstalling the app for everyone else.

Prove it with three checks

Run a bounded pilot before workspace-wide enablement. Pick two users with different channel and knowledge access, run the same prompt as each, and confirm they see different sources. Revoke one user's grant and confirm only that user's runs fail at the token lookup while the other continues. Force a denial for a private channel the requester cannot see and confirm there is no bot-token fallback. Then open one consequential write and confirm the audit row shows requester, acting principal, scopes, and outcome without reconstructing the thread. Review seven days of denials and writes with security before you expand the tools.

Shared bot tokens remain reasonable for app-identity features. They are a poor default for multi-user agents that read private context or write to real systems. If your team already uses a thread-native agent for Slack work, extend the same permission and citation rules to every connector instead of inventing a weaker shared-bot mode for convenience.

Topics

Related posts

Your team’s knowledge, answered in Slack

Add the Kipwise Agent. It answers from your pages with the source, and asks before it changes anything. 7-day trial, no card needed.