CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

ai-ecoverse/advanced-skills

Quarantined high-risk skills (browser session capture, WebSocket interception) for Slack and Microsoft Teams. Install only after reviewing the security implications.

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

endpoints.mdskills/slack/references/

Slack Web API Endpoints

Base URL: /api/ (same-origin XHR from app.slack.com) Auth: xoxc-* token from localStorage key localConfig_v2 → .teams[<workspaceId>].token Transport: XHR with Content-Type: application/x-www-form-urlencoded and withCredentials: true

Contents

Authentication

All requests include:

  • token parameter in the POST body (URL-encoded)
  • Browser cookies (automatic via withCredentials: true)

Token extraction (workspace ID determined dynamically):

const cfg = JSON.parse(localStorage.getItem('localConfig_v2'));
const token = cfg.teams[workspaceId].token;  // xoxc-…

The workspace ID (team or enterprise ID, e.g. E23RE8G4F, T06DUTYDQ) is resolved in this order:

  1. --workspace=<ID> or --ws=<ID> flag if provided
  2. Auto-detected from the active Slack tab URL: https://app.slack.com/client/<ID>/...

The localConfig_v2.teams object maps workspace IDs to { name, domain, url, token }. Keys are either enterprise IDs (E...) or team IDs (T...).

Endpoints

POST /api/conversations.history

Get messages from a channel.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
channelyesChannel ID (e.g. C087NCG774J)
limitnoNumber of messages (default 100, max 1000)
cursornoPagination cursor from response_metadata.next_cursor
oldestnoUnix timestamp — only messages after this
latestnoUnix timestamp — only messages before this

Response:

{
  "ok": true,
  "messages": [
    {
      "user": "W4SGK7ZL7",
      "type": "message",
      "ts": "1775686836.598519",
      "text": "Message text here",
      "thread_ts": "1775686836.598519",
      "reply_count": 1,
      "user_profile": {
        "real_name": "Name",
        "display_name": "handle"
      }
    }
  ],
  "has_more": true,
  "response_metadata": {
    "next_cursor": "bmV4dF90czox..."
  }
}

POST /api/conversations.replies

Get thread replies.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
channelyesChannel ID
tsyesThread parent timestamp
limitnoNumber of replies (default 100)
oldestnoUnix timestamp — only replies after this
inclusivenofalse to exclude the oldest message itself
cursornoPagination cursor

Response: Same structure as conversations.history. First message is the thread parent.

POST /api/chat.postMessage

Post a message to a channel or DM.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
channelyesChannel ID or DM ID
textyesMessage text (supports mrkdwn)
thread_tsnoReply in thread
unfurl_linksnoEnable link unfurling (default true)

Response:

{
  "ok": true,
  "channel": "D12AKTSDC",
  "ts": "1775731256.517439",
  "message": {
    "text": "Hello from SLICC!",
    "type": "message",
    "user": "W5BPKRLUA",
    "ts": "1775731256.517439"
  }
}

POST /api/reactions.add

Add an emoji reaction to a message. Used by slack react, and by slack post's auto-sign feature to "sign" the just-posted message (default icecream → 🍦).

Parameters:

ParamRequiredDescription
tokenyesxoxc token
channelyesChannel/DM ID the message is in (the RESOLVED channel — for a DM, the D… id)
timestampyesThe message ts to react to (e.g. the ts returned by chat.postMessage)
nameyesEmoji shortcode without colons (e.g. icecream, robot_face)

Response:

{ "ok": true }

Common non-fatal errors (slack post warns but still exits 0):

  • already_reacted — the reaction is already present on the message.
  • invalid_name — no emoji with that name exists in the workspace.
  • no_reaction / permission-style errors — cannot react in this context.

slack react treats only already_reacted as success (exit 0, with a note); every other error exits 1.

POST /api/reactions.remove

Remove the caller's own emoji reaction from a message. Used by slack unreact.

Parameters: same as reactions.add (channel, timestamp, name).

Response: { "ok": true }

Errors: no_reaction means the caller had not reacted with that emoji; slack unreact treats it as success (exit 0, with a note). Anything else exits 1.

POST /api/reactions.get

Read the reactions on a message. Used by slack reactions.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
channelyesChannel/DM ID
timestampyesMessage ts
fullnotrue returns the complete users list per reaction

Response (shape measured on an Enterprise Grid workspace, 2026-10-08; ids replaced):

{
  "ok": true,
  "type": "message",
  "channel": "C0123456789",
  "message": {
    "ts": "1700000000.000100",
    "reactions": [
      { "name": "ticket", "users": ["U0AAAAAAA01"], "count": 1 },
      { "name": "raised_hands::skin-tone-4", "users": ["U0BBBBBBB02"], "count": 1 }
    ]
  }
}

POST /api/conversations.open

Open or find a DM channel with a user.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
usersyesComma-separated user IDs (e.g. USLACKBOT)
return_imnoReturn full IM object

Response:

{
  "ok": true,
  "channel": {
    "id": "D12AKTSDC",
    "is_im": true,
    "user": "USLACKBOT"
  }
}

Known DM channels:

  • Slackbot: USLACKBOT → DM channel D12AKTSDC

POST /api/conversations.info

Get channel metadata.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
channelyesChannel ID

Response:

{
  "ok": true,
  "channel": {
    "id": "C087NCG774J",
    "name": "one-aem-leadership",
    "purpose": { "value": "..." },
    "topic": { "value": "..." },
    "num_members": 42
  }
}

num_members usage: slack post's auto-watch reads channel.num_members to decide watch scope — > 100 members → watch the thread only (observer selector adds thread_ts); ≤ 100 / DM / missing → watch the whole channel. DMs and some conversation types omit num_members; a missing value is treated as "small" (whole-channel watch).

POST /api/auth.test

Identify the authenticated user behind the current token. Used by slack post's auto-watch to learn our own user id, so the reply-watch webhook filter can drop every message we post ourselves (not just the one that created the watch) and your own posts never produce a notification.

Parameters:

ParamRequiredDescription
tokenyesxoxc token

Response:

{
  "ok": true,
  "url": "https://adobe.enterprise.slack.com/",
  "team": "Adobe",
  "user": "trieloff",
  "team_id": "T06DUTYDQ",
  "user_id": "W5BPKRLUA"
}

user_id usage: the auto-watch reads user_id once, at watch-creation time, and inlines it into the webhook filter (m.user === "<user_id>" → drop). The call is non-fatal: if it fails or returns no user_id, the filter falls back to dropping only the originating message's ts, and posting still succeeds.

POST /api/users.info

Get user profile information.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
useryesUser ID (e.g. W5BPKRLUA)

Response:

{
  "ok": true,
  "user": {
    "id": "W5BPKRLUA",
    "name": "trieloff",
    "real_name": "Lars Trieloff",
    "is_bot": false,
    "tz": "Europe/Amsterdam",
    "profile": {
      "display_name": "trieloff",
      "title": "...",
      "status_text": "...",
      "status_emoji": "..."
    }
  }
}

POST /api/search.modules

Search for channels by name. Replaces conversations.list which is blocked on Enterprise Grid.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
queryyesSearch query string
moduleyeschannels for channel search
countnoResults per page (default 20)
pagenoPage number (1-based)

Response:

{
  "ok": true,
  "pagination": {
    "total_count": 9,
    "page": 1,
    "per_page": 5,
    "page_count": 2
  },
  "items": [
    {
      "id": "C087NCG774J",
      "name": "one-aem-leadership",
      "member_count": 42,
      "is_member": true,
      "purpose": { "value": "..." }
    }
  ]
}

POST /api/chat.attachmentAction

Execute an interactive message button action (approve/deny). This is the internal API that Slack calls when a user clicks an action button in an attachment.

Parameters (FormData, not URL-encoded):

ParamRequiredDescription
tokenyesxoxc token
payloadyesJSON string (see below)
service_idyesB01 (Slack's internal bot service)
bot_user_idyesUSLACKBOT
client_tokennoweb-{timestamp} (dedup token)

Payload JSON structure:

{
  "actions": [
    {
      "id": "1",
      "name": "approve",
      "text": "Approve",
      "type": "button",
      "value": "",
      "style": "primary"
    }
  ],
  "attachment_id": "2",
  "callback_id": "sharedchannelinviterequests_Ir0AP9AV1A8M_T0AKE3C1LAX",
  "channel_id": "D06V5UBEZML",
  "message_ts": "1774846849.585479",
  "prompt_app_install": false
}

Key fields:

  • actions — array with one action object matching the button clicked
  • callback_id — from the message attachment (identifies the handler)
  • attachment_id — the attachment id from the message attachment object's id field
  • channel_id — channel where the message lives
  • message_ts — timestamp of the message

Response: {"ok": true} on success. Note: expired actions may return ok: true but show an error in the UI as a follow-up message.

Common callback_id patterns:

  • sharedchannelinviterequests_<invite_id>_<team_id> — Slack Connect invite
  • Workspace invite requests use different callback IDs with value containing the invite request ID

Enterprise Grid Restrictions

Some Slack workspaces use Enterprise Grid. The following standard Web API methods return enterprise_is_restricted on those workspaces:

  • conversations.list
  • users.conversations

Use these alternatives:

  • Channel discovery: search.modules with module=channels
  • DM channel lookup: conversations.open with target user IDs
  • Channel info: conversations.info with a known channel ID

POST /api/activity.feed

Get the activity feed (notifications). This is an undocumented internal API used by Slack's Activity tab.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
typesyesComma-separated activity types (see below)
modeyeschrono_reads_and_unreads or priority_reads_and_unreads_v1
limitnoNumber of items (default 20)
unread_onlynotrue or false (default false)
archive_onlynotrue or false (default false)
priority_onlynotrue or false (default false)
cursornoPagination cursor from response_metadata.next_cursor

Activity types:

  • generic_system_alert — admin events (channel archived, workspace changes)
  • bot_dm_bundle — grouped bot/app DMs (invite requests, Google Drive, etc.)
  • at_user — direct @mention
  • at_user_group — @usergroup mention
  • at_channel — @channel mention
  • at_everyone — @everyone mention
  • unjoined_channel_mention — @mention in a channel you haven't joined
  • thread_v2 — thread reply
  • message_reaction — emoji reaction on your message
  • internal_channel_invite — invited to a channel
  • external_channel_invite — Slack Connect invite
  • list_record_edited, list_record_assigned, list_user_mentioned — list items
  • list_todo_notification, list_approval_request, list_approval_reviewed — list workflows

Response:

{
  "ok": true,
  "items": [
    {
      "is_unread": true,
      "feed_ts": "1776269981.932928",
      "key": "generic_system_alert.1776269981932928",
      "item": {
        "type": "generic_system_alert",
        "generic_system_alert_payload": {
          "category": "CHANNEL",
          "blocks": [{ "type": "rich_text", "elements": [...] }],
          "click_target_id": "C05JRAM3A1H"
        }
      }
    }
  ],
  "response_metadata": {
    "next_cursor": "YWN0aXZpdHk6..."
  }
}

Item type shapes:

  • generic_system_alert → .item.generic_system_alert_payload.{category, blocks[], reason, click_target_id}
  • bot_dm_bundle → .item.bundle_info.{unread_count, payload.message.{ts, channel}}
  • at_user → .item.message.{ts, channel, author_user_id, is_broadcast}
  • internal_channel_invite → .item.invite_info.{channel_id, inviter_user_id}

Notes:

  • This endpoint works cross-workspace: use the target workspace's token even if the active Slack tab is on a different workspace.
  • The mode parameter determines sort order. chrono_reads_and_unreads returns items in reverse chronological order. priority_reads_and_unreads_v1 uses Slack's priority ranking (used by the "All" tab).

Error Handling

All responses include "ok": true|false. On error:

{
  "ok": false,
  "error": "channel_not_found"
}

Common errors:

  • channel_not_found — Invalid channel ID
  • not_in_channel — User is not a member of the channel
  • enterprise_is_restricted — Method blocked on Enterprise Grid
  • invalid_auth — Token expired or invalid
  • token_not_found — No token found for the specified workspace ID
  • ratelimited — Rate limited; check Retry-After header

Admin User-Management Methods (users.admin.*)

These are undocumented legacy methods used by slack-ext. They are NOT the same as the documented admin.users.* namespace (those return not_allowed_token_type for xoxc session tokens and need an org-level app token with admin.users:write).

Verification method (no credentials needed): POST https://slack.com/api/<method> returns {"ok":false,"error":"not_authed"} for real methods and {"ok":false,"error":"unknown_method"} for nonexistent ones. All methods below were verified real on 2026-09-18.

Token requirement: these methods reject bot tokens (xoxb) with not_allowed_token_type. They work only with an xoxc admin user token. The xoxc token MUST travel with Slack's d session cookie; slack-ext.jsh uses browser.fetch (same-origin XHR) which sends cookies automatically.

Audit note: calls are attributed in Slack's audit log to the admin user whose token is in use, not to an app.

POST /api/users.admin.setUltraRestricted

Convert a user to a single-channel guest (ultra-restricted).

Parameters:

ParamRequiredDescription
tokenyesxoxc admin user token
useryesUser ID (e.g. W5BPKRLUA)
team_idyesWorkspace team ID (e.g. T06DUTYDQ)
channelyesSingular — the one channel the guest may access

GOTCHA — channel vs channels: the parameter is channel (singular). Passing channels (plural) returns invalid_arguments. Verified both ways 2026-09-18. The slack-ext.jsh code and its tests enforce this.

Response on success: {"ok": true}

Verification probe (no auth needed):

POST https://slack.com/api/users.admin.setUltraRestricted  →  not_authed  (method exists)
POST https://slack.com/api/admin.users.setUltraRestricted  →  unknown_method  (DOES NOT EXIST)

Test with bogus user: user=U000000BOGUS0 returns user_not_found, confirming auth, permissions, and parameter shape without changing anyone.

POST /api/users.admin.setRestricted

Convert a user to a multi-channel guest (restricted).

Parameters:

ParamRequiredDescription
tokenyesxoxc admin user token
useryesUser ID
team_idyesWorkspace team ID

No channel parameter. After converting, use conversations.invite to grant channel access.

Response on success: {"ok": true}

Verification probe:

POST https://slack.com/api/users.admin.setRestricted   →  not_authed  (real)
POST https://slack.com/api/admin.users.setRestricted   →  unknown_method  (DOES NOT EXIST)

POST /api/users.admin.setRegular

Promote a guest back to a regular member. The inverse of setRestricted and setUltraRestricted.

Parameters:

ParamRequiredDescription
tokenyesxoxc admin user token
useryesUser ID
team_idyesWorkspace team ID

Response on success: {"ok": true}

POST /api/conversations.invite (for guest channel management)

Invite a user (including a multi-channel guest) to a channel. Used by slack-ext add-channel.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
channelyesChannel ID
usersyesComma-separated user IDs

Common errors:

  • already_in_channel — user is already a member (treated as no-op)
  • cant_invite_self — cannot invite the token owner

POST /api/conversations.kick (for guest channel management)

Remove a user from a channel. Used by slack-ext remove-channel.

Parameters:

ParamRequiredDescription
tokenyesxoxc token
channelyesChannel ID
useryesUser ID (singular)

Common errors:

  • not_in_channel — user is not in the channel (treated as no-op)
  • cant_kick_self — cannot kick the token owner
  • cant_kick_from_general — some workspaces protect #general

Enterprise Grid channel search (admin.conversations.search)

Endpoint contract used by slack-ext channel-search and by channel-archive / channel-unarchive. The archive and unarchive methods, the wire facts measured on this method (the query=<channel id> lookup, microsecond timestamps, member_count: -1, the index lag) and how slack-ext uses them: references/enterprise-grid.md, "Enterprise Grid Channel Admin".

POST /api/admin.conversations.search

Org-wide channel search; the only state read that sees private channels the admin is not in (conversations.info answers channel_not_found for those).

ParamRequiredDescription
tokenyesorg-level xoxc token
queryyesMay be empty. Matches word PREFIXES of the name (split on -): on 2026-10-02 santander found #agents-santander but not #aem-gruposantander (C09U7MX5R7X, since renamed #aem-santander), and antander found nothing. query=<channel id> finds that channel (see references/enterprise-grid.md)
limityes2 to 20. limit=21, limit=50 and limit=100 answer invalid_arguments with response_metadata.messages: ["[ERROR] must be less than 20 [json-pointer:/limit]"] (20 is accepted despite the wording: limit=18/19/20 return 18/19/20 rows, measured 2026-10-02 with an empty query; every other param is accepted on its own at limit=5); limit=0 answers must be greater than 1; limit=1 answers ok:true with zero rows and no next_cursor. slack-ext channel-search pages 20 at a time and clamps --limit into 2..20
search_channel_typesyesall, exclude_archived, private, private_exclude, archived. private_archive answers invalid_search_channel_type
sortyesname, member_count, created (last_activity_ts answers invalid_sort)
sort_diryesasc / desc
cursoryesEmpty for the first page; then next_cursor

Response: {ok, conversations: [...], next_cursor, total_count}. On invalid_arguments the reason is in response_metadata.messages (a JSON-pointer to the rejected param). Fields used by channel-archive: id, name, is_private, is_archived, member_count, external_user_count, is_ext_shared, is_pending_ext_shared, is_org_shared, conversation_host_id, last_activity_ts.

Slack Connect invites and guest invites

Endpoint contracts used by slack-ext approvals, connect-revoke, guest-invite, connect-invite, connect-permissions and connect-status. Measured 2026-09-29 on an Enterprise Grid, XHR from the app.slack.com page with the org-level token (localConfig_v2.teams[<E id>]), the same token path as the other org commands. Ids below are fakes.

POST /api/conversations.sharedApprovals.list

Read-only. slack-ext finds an invite by matching the row id (or invite_id) against the I… id, paging until found. Fields shown by approvals --detail / approvals show:

FieldMeaning
home_userInviter (real_name, id)
away_userInvitee (real_name, id, team_id, profile.email)
connecting_teamInvitee org: id, name, domain, requires_sponsorship
home_date_approveOur side's approval time; 0 = not approved
away_date_approveOther org's approval time; 0 = the other org has NOT approved
approving_user_id, invite_date_created, date_expire, connection_status, statusAs named

Icon and avatar fields (connecting_team.icon, profile.image_*) are dropped from all output.

POST /api/conversations.revokeSharedInvite

ParamRequiredDescription
tokenyesorg-level xoxc token
invite_idyesI…, e.g. I0EXAMPLE01
channelyesC…, e.g. C0EXAMPLE01
  • Org token: {"ok":true}. Afterwards sharedApprovals.list shows the invite with status:"expired" and date_expire ≈ now. connect-revoke --confirm re-reads the row and exits 3 unless it shows expired.
  • Workspace-scoped token (the one in <workspace>.slack.com/admin boot_data): {"ok":false,"error":"team_is_restricted"}, and nothing changes.

POST /api/users.admin.inviteBulk

Invites a guest to a workspace.

ParamRequiredDescription
tokenyesorg-level xoxc token
team_idyes (grid)The workspace id (T…), not the org id
invitesyesJSON string: [{"email":"guest@example.com","type":"ultra_restricted","mode":"manual"}]
channelsyesChannel id (C…); the web client sends a comma list
ultra_restrictedyestrue
sourceyesinvite_modal
modeyesmanual

Response: {"ok":true,"invites":[{"email":"guest@example.com","ok":true,"invite_id":"I0EXAMPLE01","expiration_ts":<unix>}]}. A top-level ok:true can carry a per-invite ok:false; guest-invite reports each entry and exits 1 if any failed. Only type:"ultra_restricted" (single-channel guest) was tested; multi-channel (restricted) is not offered. What expiration_ts ends (the invite, or the guest account) is unverified; it is printed as "Expires".

POST /api/conversations.inviteShared

Used by slack-ext connect-invite. Invites people from an external org into a channel by email. Workspace token (localConfig_v2.teams[<T id>], selected with --ws=<T…>). MEASURED live 2026-10-15 on workspace T0385CHDU9E: {"ok":true}.

ParamRequiredDescription
tokenyesworkspace xoxc token
channelyesC…
emailsyesONE address, e.g. a@example.com (see below)
external_limitedno (Slack default true)true = post-only, false = Post and Invite
  • One address per call. Slack's method documentation: "only one email or one user ID may be invited at a time"; a longer list answers too_many_emails. connect-invite --emails=a,b makes one call per address, prints each result (invite_id on success, per the documented success body {"ok":true,"invite_id":"I…"}), and exits 1 if any address failed.
  • Rate limits: the method documents BOTH ratelimit ("applied on a per-user basis when you pass the emails parameter") and ratelimited. connect-invite retries both (4 tries, 30 s apart). Retry-After is not honoured: the tab fetch wrapper returns the body only.
  • Ambiguous answers: internal_error and fatal_error are documented with "It's possible some aspect of the operation succeeded before the error was raised". connect-invite treats them (and a transport xhr_error) as outcome UNKNOWN: not retried (a retry could send a second invitation), never reported as "nothing invited", exit 3.
  • Slack's default for external_limited is true (post-only). connect-invite therefore ALWAYS sends it: false by default, true only with --post-only.
  • Right after {"ok":true} the channel is unchanged in conversations.info (is_ext_shared:false, is_pending_ext_shared:false, pending_connected_team_ids:[]), and the invite is not in conversations.sharedApprovals.list until the invitee accepts. There is no read that shows a sent, unaccepted invite with this token: conversations.listConnectInvites answers not_allowed_token_type. "No visible change" is the expected read-back, not a failure.
  • Measured from approvals history: an invite the other org approved can still sit at "Waiting on: our side" and expire. Follow up with slack-ext approvals --query=<channel name> --detail.

POST /api/conversations.externalInvitePermissions.set

Used by slack-ext connect-permissions. Changes what one connected external org may do in a channel. Workspace token. Used live 2026-10-02 to upgrade 57 org connections.

ParamRequiredDescription
tokenyesworkspace xoxc token
channelyesC…
target_teamyesThe connected org id as listed in connected_team_ids (e.g. E…)
actionyesupgrade = post-only → Post and Invite; downgrade = the reverse
  • Can answer {"ok":false,"error":"ratelimited"} during a batch. connect-permissions retries that error (and ratelimit) only (4 tries, 30 s apart, as the 2026-10-02 batch did). internal_error / fatal_error are outcome UNKNOWN: not retried, read back, and exit 3 even if the read-back matches.
  • Effect is read from conversations.info connected_limited_team_ids: after an upgrade the org is no longer listed. A team that DISCONNECTED is not listed either, so the read-back only counts when the team is still in connected_team_ids. connect-permissions --confirm re-reads up to 5 times, 3 s apart, and exits 3 if that never holds.

Slack Connect state from conversations.info and team.info

connect-status, connect-invite and connect-permissions read the channel from conversations.info with the workspace token. Fields used (measured 2026-10-15):

FieldMeaning
is_ext_shared, is_pending_ext_sharedBooleans. Missing or non-boolean = sharing-unknown, refused
conversation_host_idHost org; present on shared channels only. Not this org = hosted-elsewhere, refused
connected_team_idsEvery connected org, ours included (e.g. ["E0EXAMPLE02","E0EXAMPLE01"])
connected_limited_team_idsThe subset that is post-only; the rest of the external orgs are Post and Invite
pending_connected_team_idsOrgs with a pending connection
internal_team_ids, context_team_idOur own workspace ids, excluded from "external"

A channel that was never shared has no connected_team_ids or connected_limited_team_ids at all; they are reported as not present (null), never as an empty list.

team.info (team) resolves an org or workspace id to {id, name, domain}. An external grid's workspace id resolves to the grid's E… id (measured: a T… of a partner grid returned that grid's E… as team.id). Slack's documented shape keeps team.id as the workspace and gives the grid as team.enterprise_id. connect-permissions --team=<T…> accepts either candidate, but only if it is listed in connected_team_ids. An unknown id answers team_not_found; connect-status then prints the bare id.

Methods observed unavailable with this token (2026-09-29)

MethodError
users.admin.fetchInvitesunknown_method
users.admin.fetchInvitesHistoryenterprise_is_restricted
users.lookupByEmailnot_allowed_token_type
admin.users.listnot_allowed_token_type
conversations.listConnectInvitesnot_allowed_token_type (workspace xoxc token, 2026-10-15)

App Manifest API (apps.manifest.*, tooling.tokens.rotate)

Used by slack-ext app. Two credentials are accepted:

  • an app configuration token (xoxe.xoxp-...) in an Authorization: Bearer header, sent to https://slack.com/api/ over plain HTTPS; or
  • the xoxc session token, sent like every other endpoint in this document: POST /api/<method> through the Slack tab (same-origin, with the d cookie), token as the token form field. Measured 2026-10-02 against app A0C2DNYR0TF: apps.manifest.export (with both the workspace token T0385CHDU9E and the grid token E06V3987PMY), apps.manifest.validate and apps.manifest.update all returned ok:true. The user was an org admin and an app collaborator; a non-collaborator's error is unverified. developer.apps.manifest.export answers unknown_method.

slack-ext app tries them in the order --token > $SLACK_APP_CONFIG_TOKEN > config appConfigToken > session (--session / --no-session override) and prints which one it used. See references/app-manifest.md. The xoxb bot token is rejected.

Transport: Content-Type: application/x-www-form-urlencoded. A JSON request body is rejected with invalid_arguments. The manifest parameter is a JSON string, not a nested object.

Failure is signalled in the body, not the status. Every failure observed returned HTTP 200 with {"ok":false,"error":"..."} — a bogus bearer token gave invalid_auth, a bad app id gave invalid_app_id. Check body.ok.

Getting a config token is a manual browser step: api.slack.com/apps → "Your App Configuration Tokens" → Generate Token → pick a workspace → Generate. (The workspace picker is a Slack Kit .c-basic-select that ignores synthetic events entirely.) The session path needs none. tooling.tokens.rotate is config-token only.

POST /api/apps.manifest.export

Fetch the live manifest of an app. Used by slack-ext app export, app show and app diff.

Parameters:

ParamRequiredDescription
app_idyesApp ID (e.g. A0123456789)

Returns: {ok: true, manifest: {...}}. A real manifest is small: the app used to verify this had 14 leaf fields / 709 bytes — display_information (name, description, background_color), features.bot_user (display_name, always_online), oauth_config (scopes.bot[], pkce_enabled), and settings (event_subscriptions.request_url, event_subscriptions.bot_events[], org_deploy_enabled, socket_mode_enabled, token_rotation_enabled, app_level_token_rotation_enabled, is_mcp_enabled).

Common errors: invalid_auth (bad/expired config token), invalid_app_id, app_not_found.

POST /api/apps.manifest.validate

Validate a candidate manifest without changing anything. Used by slack-ext app validate.

Parameters:

ParamRequiredDescription
manifestyesThe manifest as a JSON string
app_idnoValidate against an existing app

Returns: {ok: true, errors: []} when valid. When invalid: {ok: false, error: "invalid_manifest", errors: [...]}, where each error carries a JSON pointer — captured live:

{"ok":false,"error":"invalid_manifest","errors":[{"code":"illegal_bot_scopes",
"message":"Illegal bot scopes found `this:is:not:a:real:scope`",
"pointer":"/oauth_config/scopes/bot"}]}

A PASS DOES NOT MEAN SAFE. A display_information-only payload returns ok:true, errors: [] (confirmed live), even though applying it would strip the bot user, every scope and every event subscription. Validation catches only some incoherence (omitting oauth_config fails with requires_a_bot_scope@/features/bot_user and target_component_is_null@/settings/event_subscriptions), which is worse than blanket rejection: the dangerous payloads are the ones that pass. Use slack-ext app diff before applying a manifest.

POST /api/apps.manifest.update

Parameters app_id + manifest (a JSON string); returns {ok, permissions_updated}. Used by slack-ext app set-scopes, set-events, set-request-url and apply — all of which route through one internal helper that exports the live manifest first and sends the complete result.

Measured semantics, which every write path must respect:

  • No merge semantics: an omitted field is DELETED (omitting display_information.description removed it).
  • Arrays are REPLACED WHOLESALE (bot_events: ["channel_created"] removed team_join).
  • The single exception measured was display_information.background_color, which survived omission because it can never be null. One field, not a pattern.
  • permissions_updated: true means a REINSTALL is required: a scope added to the configuration does not reach the live bot token until the app is reinstalled.

So a write must always export the live manifest, modify that object, and send the complete result. slack-ext enforces that structurally: updateFromLiveManifest is the only call site for this method, it refuses to proceed when the export failed or returned no manifest, and it blocks any deletion the command was not explicitly asked to make unless --allow-deletions is passed on top of --confirm.

permissions_updated is surfaced on every write. When it is true the operator is told to reinstall the app at api.slack.com/apps/<app_id>/install-on-team, because the previously issued bot token does not carry the new scope until it is reissued.

POST /api/tooling.tokens.rotate

Rotates an app configuration token; used by slack-ext app token-rotate. Returns {ok, token, refresh_token, team_id, user_id, iat, exp}. Authenticates by argument, not header: refresh_token=<bogus> returns invalid_refresh_token (param name confirmed), token=<bogus> returns invalid_auth, and no params returns invalid_arguments with missing required field: refresh_token. Sending an Authorization: Bearer header alongside a bogus refresh_token returned invalid_auth, so the header takes precedence — a rotate call should be made without one.

Hazard: a rotate invalidates the old refresh token. If the process dies between rotating and persisting, the credential is lost permanently and only a human can mint a replacement. slack-ext app token-rotate therefore requires --confirm (never rotate speculatively — only on demand or after a 401), writes the new pair to the skill config before anything else happens with it, and falls back to printing the pair on stdout if that write fails: the old refresh token is already dead by then, so surfacing the credential beats losing it. A response that is ok but missing either half of the pair is refused rather than treated as a successful rotation.

POST /api/apps.manifest.create, POST /api/apps.manifest.delete — never wired up

Both are real methods (not_authed on an unauthenticated probe). slack-ext refuses both by name before any request is made: deleting a Slack app is unrecoverable and there is no reason for a CLI to offer it. Use api.slack.com/apps if you really mean it.

Probing a method name without a credential

curl -s -X POST https://slack.com/api/<method> with no auth distinguishes real from imaginary methods: a real method answers {"ok":false,"error":"not_authed"}, a nonexistent one answers {"ok":false,"error":"unknown_method"}. No credential, no side effects. All five apps.manifest.* methods answer not_authed; apps.manifest.nonexistent answers unknown_method.

tile.json