Quarantined high-risk skills (browser session capture, WebSocket interception) for Slack and Microsoft Teams. Install only after reviewing the security implications.
76
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
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
users.admin.*)
admin.conversations.search)
apps.manifest.*, tooling.tokens.rotate)
All requests include:
token parameter in the POST body (URL-encoded)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:
--workspace=<ID> or --ws=<ID> flag if providedhttps://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...).
Get messages from a channel.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| channel | yes | Channel ID (e.g. C087NCG774J) |
| limit | no | Number of messages (default 100, max 1000) |
| cursor | no | Pagination cursor from response_metadata.next_cursor |
| oldest | no | Unix timestamp — only messages after this |
| latest | no | Unix 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..."
}
}Get thread replies.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| channel | yes | Channel ID |
| ts | yes | Thread parent timestamp |
| limit | no | Number of replies (default 100) |
| oldest | no | Unix timestamp — only replies after this |
| inclusive | no | false to exclude the oldest message itself |
| cursor | no | Pagination cursor |
Response: Same structure as conversations.history. First message is the thread parent.
Post a message to a channel or DM.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| channel | yes | Channel ID or DM ID |
| text | yes | Message text (supports mrkdwn) |
| thread_ts | no | Reply in thread |
| unfurl_links | no | Enable link unfurling (default true) |
Response:
{
"ok": true,
"channel": "D12AKTSDC",
"ts": "1775731256.517439",
"message": {
"text": "Hello from SLICC!",
"type": "message",
"user": "W5BPKRLUA",
"ts": "1775731256.517439"
}
}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:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| channel | yes | Channel/DM ID the message is in (the RESOLVED channel — for a DM, the D… id) |
| timestamp | yes | The message ts to react to (e.g. the ts returned by chat.postMessage) |
| name | yes | Emoji 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.
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.
Read the reactions on a message. Used by slack reactions.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| channel | yes | Channel/DM ID |
| timestamp | yes | Message ts |
| full | no | true 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 }
]
}
}Open or find a DM channel with a user.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| users | yes | Comma-separated user IDs (e.g. USLACKBOT) |
| return_im | no | Return full IM object |
Response:
{
"ok": true,
"channel": {
"id": "D12AKTSDC",
"is_im": true,
"user": "USLACKBOT"
}
}Known DM channels:
USLACKBOT → DM channel D12AKTSDCGet channel metadata.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| channel | yes | Channel 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).
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:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc 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.
Get user profile information.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| user | yes | User 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": "..."
}
}
}Search for channels by name. Replaces conversations.list which is blocked on Enterprise Grid.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| query | yes | Search query string |
| module | yes | channels for channel search |
| count | no | Results per page (default 20) |
| page | no | Page 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": "..." }
}
]
}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):
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| payload | yes | JSON string (see below) |
| service_id | yes | B01 (Slack's internal bot service) |
| bot_user_id | yes | USLACKBOT |
| client_token | no | web-{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 clickedcallback_id — from the message attachment (identifies the handler)attachment_id — the attachment id from the message attachment object's id fieldchannel_id — channel where the message livesmessage_ts — timestamp of the messageResponse: {"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 invitevalue containing the invite request IDSome Slack workspaces use Enterprise Grid. The following standard Web API methods
return enterprise_is_restricted on those workspaces:
conversations.listusers.conversationsUse these alternatives:
search.modules with module=channelsconversations.open with target user IDsconversations.info with a known channel IDGet the activity feed (notifications). This is an undocumented internal API used by Slack's Activity tab.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| types | yes | Comma-separated activity types (see below) |
| mode | yes | chrono_reads_and_unreads or priority_reads_and_unreads_v1 |
| limit | no | Number of items (default 20) |
| unread_only | no | true or false (default false) |
| archive_only | no | true or false (default false) |
| priority_only | no | true or false (default false) |
| cursor | no | Pagination 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 @mentionat_user_group — @usergroup mentionat_channel — @channel mentionat_everyone — @everyone mentionunjoined_channel_mention — @mention in a channel you haven't joinedthread_v2 — thread replymessage_reaction — emoji reaction on your messageinternal_channel_invite — invited to a channelexternal_channel_invite — Slack Connect invitelist_record_edited, list_record_assigned, list_user_mentioned — list itemslist_todo_notification, list_approval_request, list_approval_reviewed — list workflowsResponse:
{
"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:
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).All responses include "ok": true|false. On error:
{
"ok": false,
"error": "channel_not_found"
}Common errors:
channel_not_found — Invalid channel IDnot_in_channel — User is not a member of the channelenterprise_is_restricted — Method blocked on Enterprise Gridinvalid_auth — Token expired or invalidtoken_not_found — No token found for the specified workspace IDratelimited — Rate limited; check Retry-After headerusers.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.
Convert a user to a single-channel guest (ultra-restricted).
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc admin user token |
| user | yes | User ID (e.g. W5BPKRLUA) |
| team_id | yes | Workspace team ID (e.g. T06DUTYDQ) |
| channel | yes | Singular — 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.
Convert a user to a multi-channel guest (restricted).
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc admin user token |
| user | yes | User ID |
| team_id | yes | Workspace 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)Promote a guest back to a regular member. The inverse of
setRestricted and setUltraRestricted.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc admin user token |
| user | yes | User ID |
| team_id | yes | Workspace team ID |
Response on success: {"ok": true}
Invite a user (including a multi-channel guest) to a channel. Used by
slack-ext add-channel.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| channel | yes | Channel ID |
| users | yes | Comma-separated user IDs |
Common errors:
already_in_channel — user is already a member (treated as no-op)cant_invite_self — cannot invite the token ownerRemove a user from a channel. Used by slack-ext remove-channel.
Parameters:
| Param | Required | Description |
|---|---|---|
| token | yes | xoxc token |
| channel | yes | Channel ID |
| user | yes | User ID (singular) |
Common errors:
not_in_channel — user is not in the channel (treated as no-op)cant_kick_self — cannot kick the token ownercant_kick_from_general — some workspaces protect #generaladmin.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".
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).
| Param | Required | Description |
|---|---|---|
| token | yes | org-level xoxc token |
| query | yes | May 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) |
| limit | yes | 2 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_types | yes | all, exclude_archived, private, private_exclude, archived. private_archive answers invalid_search_channel_type |
| sort | yes | name, member_count, created (last_activity_ts answers invalid_sort) |
| sort_dir | yes | asc / desc |
| cursor | yes | Empty 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.
Endpoint contracts used by slack-ext approvals, connect-revoke and guest-invite. 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.
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:
| Field | Meaning |
|---|---|
home_user | Inviter (real_name, id) |
away_user | Invitee (real_name, id, team_id, profile.email) |
connecting_team | Invitee org: id, name, domain, requires_sponsorship |
home_date_approve | Our side's approval time; 0 = not approved |
away_date_approve | Other org's approval time; 0 = the other org has NOT approved |
approving_user_id, invite_date_created, date_expire, connection_status, status | As named |
Icon and avatar fields (connecting_team.icon, profile.image_*) are dropped from all output.
| Param | Required | Description |
|---|---|---|
| token | yes | org-level xoxc token |
| invite_id | yes | I…, e.g. I0EXAMPLE01 |
| channel | yes | C…, e.g. C0EXAMPLE01 |
{"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>.slack.com/admin boot_data):
{"ok":false,"error":"team_is_restricted"}, and nothing changes.Invites a guest to a workspace.
| Param | Required | Description |
|---|---|---|
| token | yes | org-level xoxc token |
| team_id | yes (grid) | The workspace id (T…), not the org id |
| invites | yes | JSON string: [{"email":"guest@example.com","type":"ultra_restricted","mode":"manual"}] |
| channels | yes | Channel id (C…); the web client sends a comma list |
| ultra_restricted | yes | true |
| source | yes | invite_modal |
| mode | yes | manual |
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".
| Method | Error |
|---|---|
users.admin.fetchInvites | unknown_method |
users.admin.fetchInvitesHistory | enterprise_is_restricted |
users.lookupByEmail | not_allowed_token_type |
admin.users.list | not_allowed_token_type |
apps.manifest.*, tooling.tokens.rotate)Used by slack-ext app. Two credentials are accepted:
xoxe.xoxp-...) in an
Authorization: Bearer header, sent to https://slack.com/api/ over plain
HTTPS; orxoxc 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.
Fetch the live manifest of an app. Used by slack-ext app export, app show
and app diff.
Parameters:
| Param | Required | Description |
|---|---|---|
| app_id | yes | App 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.
Validate a candidate manifest without changing anything. Used by
slack-ext app validate.
Parameters:
| Param | Required | Description |
|---|---|---|
| manifest | yes | The manifest as a JSON string |
| app_id | no | Validate 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.
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:
display_information.description removed it).bot_events: ["channel_created"] removed
team_join).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.
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.
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.
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.