← Slack Submit
API
Tokens

Run Slack Submit from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page parses the manifest, builds the scope table and classes every scope, runs every check, screens the app against Slack's unsuitable list, ranks the install-gate menu and plans the review timeline, then hands it all to the run as facts. The run plans the submission or fixes a returned one; the page then holds it to its own facts (slreply.js). Slack Submit is not affiliated with or endorsed by Slack.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"slack-submit"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, app, manifest, features, facts) is missing. The page always sends all seven of task, app, manifest, features, feedback, carry, facts; feedback and carry are empty strings for plan.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - the submission, then a returned submission:

A missing or unknown task is answered as plan. Both lanes send the same seven fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the plan's scope table into carry for the fixes lane. An app on Slack's unsuitable list, or a coded workflow, is refused and the page never sends it.

The input

FieldRequiredWhat
taskyesplan or fixes. If it is missing or neither, do plan and set "task": "plan".
appyesthe app's name, the stage, the active workspaces and weekly active users, the launch date and days to it, and the interview answers; "not given" means not given.
manifestyesthe manifest as the page read it, field by field (names, commands, URLs, scopes per token, events, settings).
featuresyeswhat the app does in Slack, then the feature notes, one per scope; "none given" means none.
feedbacksend it (the returned feedback for fixes, empty for plan)fixes task only: the returned review feedback as pasted.
carrysend it (empty for plan)fixes task only: the submission plan's scope table and fixes, when the plan ran first; may be empty.
factsyesthe page's checks, one line each with the rule, the id, the status and the evidence separated by vertical bars; then the scope table, the install-gate menu, the timeline, the stage, the figures and the footnote. A line starting "must:" applies to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skill, @samber/slack-app-directory-playbook - the only standard the run applies:

House rules

The replies

plan

Plan the submission: eligibility, the scope table with one justification per scope, the install gate, one fix per failing check, the packet and the timeline.

  1. summary - one sentence, at most 40 words: what stands between the app and approval today and what the plan changes.
  2. eligibility - an object: verdict (exactly the facts' eligibility: eligible, not_yet or refused), why (one or two sentences from the install count and the checks).
  3. scopes - exactly one object per scope the manifest requests, in the facts' scope-table order: scope (spelled exactly as in the manifest), token (bot or user), required (true for a scope listed as required, false for an optional one), justification (one sentence: how this app uses the scope - the feature, the user action that triggers it, what it does with the data - from the description or the feature notes; write "needs a feature note" when the input gives no feature for it; never a feature the input does not have, never the scope's definition, never the same sentence for two scopes), action (keep, optional or drop; drop for every scope the page fails). No other scope.
  4. gate - the install-gate rungs in exactly the facts' order, each an object: rung (customers, partners or staff), action (one sentence for this app; when the gate is cleared, how to keep the count through the review).
  5. fixes - one object per check the facts mark fail, and any warn worth fixing: check (the page's check id), change (one concrete sentence: what to change in the manifest, the scopes, the packet or the pilot). Every fail check appears.
  6. packet - the submission packet items still open or worth confirming, each an object: item (one sentence), owner (a role, not a name), done (true or false).
  7. timeline - the steps from here to listed, each an object: step (one sentence), wait (the wait, with † on every Slack figure; no dates).
  8. footnote - exactly the skill's footnote.
  9. caveats - at most 4 short strings: what the team must confirm; [] when there is nothing to say.
{
  "task": "plan",
  "summary": "one sentence",
  "eligibility": { "verdict": "not_yet", "why": "one or two sentences" },
  "scopes": [ { "scope": "chat:write", "token": "bot", "required": true, "justification": "one sentence", "action": "keep" } ],
  "gate": [ { "rung": "customers", "action": "one sentence" } ],
  "fixes": [ { "check": "install_gate", "change": "one sentence" } ],
  "packet": [ { "item": "one sentence", "owner": "Engineering", "done": false } ],
  "timeline": [ { "step": "one sentence", "wait": "one phrase" } ],
  "footnote": "† verify against current Slack Marketplace docs, because Slack changed its install gate twice in a year.",
  "caveats": [ ]
}

The page holds it to its own facts (check() in slreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL; no backticks; no implied Slack endorsement; a summary of at most 40 words; every Slack figure with its dagger and the exact footnote; the page's eligibility (a different verdict is put back and flagged); exactly one scope row per manifest scope and token, none invented, the manifest's required flag, an action of keep, optional or drop, drop for every scope the page fails, a justification on each and never one justification for two scopes; the install-gate rungs in the page's order; a fix for every fail check and only the page's check ids; a packet before submission, an owner on every item; a timeline.

fixes

Fix the returned submission: one fix per point the feedback raises, the single resubmission and its queue effect, then the notes to the reviewers.

  1. summary - one sentence, at most 40 words: what the reviewers returned and what the resubmission changes.
  2. points - one object per distinct point the feedback raises, in its order: n (1, 2, 3...), feedback (a short quote or paraphrase of the point, from the feedback), fix (the concrete change, one sentence), check (the page's check id the point matches, or ""). When the carried plan has a fix or a scope row for that check, use it.
  3. resubmit - an object: stage (exactly the facts' resubmission stage: preliminary or functional), queue (the queue effect: a preliminary resubmission resets your queue position; a functional resubmission keeps your place), when (one sentence: one resubmission once every fix is in, in one pass).
  4. notes - the note to the reviewers that goes with the resubmission: thanks them, lists every point as a numbered line with its fix, says every fix is in this one resubmission; polite, never argues, never asks to expedite, never claims endorsement, no URL.
  5. footnote - exactly the skill's footnote.
  6. caveats - at most 4 short strings: what to confirm before resubmitting; [] when there is nothing to say.
{
  "task": "fixes",
  "summary": "one sentence",
  "points": [ { "n": 1, "feedback": "short quote or paraphrase", "fix": "one sentence", "check": "urls_public" } ],
  "resubmit": { "stage": "preliminary", "queue": "one phrase", "when": "one sentence" },
  "notes": "the note to the reviewers, one numbered line per point",
  "footnote": "† verify against current Slack Marketplace docs, because Slack changed its install gate twice in a year.",
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; the same text rules; at least as many points as the feedback numbers, only the page's check ids, a fix on each; the resubmission stage the feedback came from (preliminary or functional) with its queue effect - a preliminary resubmission resets the queue position, a functional one keeps it - and a when; notes that list every point as a numbered line, never ask to expedite and never argue.

Worked examples

Three runs from the page's free samples (invented apps, hosts on the reserved .example domain but for one temporary tunnel): the submission plan for an incident-alerts app with an ineligible scope, the fixes for its returned submission carrying that plan, and the plan for an AI catch-me-up app launching before approval can land. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the long fields, strings and lists are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

plan - Incident alerts with an ineligible scope

The page read it as: returned as it stands; stage unlisted pilot; eligibility not_yet; scopes chat:write (bot), commands (bot), channels:read (bot), channels:history (user), search:read (user); to drop: channels:history, search:read; gate order customers > partners > staff; fail checks: install_gate, ineligible_scopes, enhanced_scopes, test_commands, urls_public, test_accounts.

Input (facts is 7051 characters; the first 1398 are shown):

{
 "task": "plan",
 "app": "App: Flarebell (staging) (bot user: Flarebell)\nStage: unlisted pilot\nActive workspaces in the last 28 days†, sandboxes excluded: 6; weekly active users: 31\nLaunch date: not given; an event or announcement is pinned to it: no\nSends Slack data to an LLM: no; an automation platform connecting other services: no; Enterprise Grid or Slack Connect customers: no\nListed-change kind: none\nReviewer installs: a self-install link: yes; test accounts with dummy data: no; a demo video: no",
 "manifest": "Manifest (YAML), the fields the review reads:\ndisplay_information: name Flarebell (staging); description Incident alerts and on-call acks in Slack; background_color #7a1f1f\nlong_description: Flarebell posts an alert card to the channel your team picks when a monitor fires, pages the on-call engineer with /flarebell, and lets anyone acknowledge the incident from the card. Search past incidents without leaving Slack.\nbot_user: Flarebell; always_online on\nslash_commands: /flarebell (Page the on-call engineer; usage [service] [message]; url https://abcd-1234.ngrok-free.app/slack/commands); /alerts-test (Send a test alert to this channel; url https://abcd-1234.ngrok-free.app/slack/commands)\nshortcuts: none\napp_home: not set\nredirect_urls: https://abcd-1234.ngrok-free.app/slack/oauth\nscopes.bot: chat:write, commands, channels:read\nscopes.bot_optional: none\nscopes.user: channels:history, search:read\nscopes.user_optional: none\nevent_subscriptions: request_url https://abcd-1234.ngrok-free.app/slack/events; bot_events app_mention; user_events none\ninteractivity: on; request_url https://abcd-1234.ngrok-free.app/slack/actions\nsettings: org_deploy_enabled off; socket_mode_enabled off; token_rotation_enabled off",
 "features": "What the app does in Slack:\nWhen a monitor fires, Flarebell posts an alert card to the channel the team picked in setup; /flarebell pages the on-call engineer; anyone in the channel can acknowledge the incident from the card's button. Incident search is planned for next quarter.\n\nFeature notes, one per scope (scope | what the feature does with it):\nchat:write | posts the alert card to the channel picked in /flarebell setup, and nowhere else\ncommands | /flarebell pages the on-call engineer for a service\nchannels:read | lists public channels for the alert-routing picker in setup",
 "feedback": "",
 "carry": "",
 "facts": "The page's checks (6 fail, 4 warn, 8 pass, 0 info, 10 n/a); verdict: returned as it stands; eligibility: not_yet; rule | id | status | evidence:\n- 1 | install_gate | fail | 6 active workspaces - below the 10 active workspaces† gate: Slack blocks the submission below it; pilot as an unlisted distributed app until then\n- 1 | weekly_users | pass | 31 weekly active users\n- 2 | scope_features | warn | no feature note for channels:history (user), search:read (user) - justification needs a feature; a scope for the roadmap returns the submission\n- 2 | optional_scopes | na | no scope marked optional (bot_optional, user_optional)\n- 3 | ineligible_scopes | fail | ineligible: search:read (user) - remove each one; Slack does not list an app that requests it\n- 3 | partner_scopes | pass | no admin.* scope\n- 3 | enhanced_scopes | fail | enhanced review with no feature note while unlisted pilot: channels:history (user) - drop it or justify it with a feature that exists today\n- 3 | user_token | warn | user-token scopes with no note saying the app acts as the user: channels:history (user), search:read (user) - bot token by default; a user token only when the app must act as that user\n- 3 | token_duplicates | pass | no scope on both tokens\n- elig | history_pilot | pass | history scopes (channels:history (user)) with 1 event subscribed - drive pilot features from events; new unlisted installs get\n\n[... 5653 more characters cut from this display ...]"
}

Reply (shortened for display - see the cuts below):

{
 "task": "plan",
 "summary": "Six active workspaces, an ineligible search:read scope, an unjustified user-token history scope, a test command, tunnel URLs and no test accounts block submission; the plan clears each before the first submission.",
 "eligibility": {
  "verdict": "not_yet",
  "why": "Flarebell has 6 active workspaces, below the 10 active workspaces† gate counted over the last 28 days†, and Slack blocks the submission below it. Nothing in the description puts it on the unsuitable list."
 },
 "scopes": [
  {
   "scope": "chat:write",
   "token": "bot",
   "required": true,
   "justification": "Posts the alert card to the channel the team picked in /flarebell setup when a monitor fires, and posts nowhere else.",
   "action": "keep"
  },
  {
   "scope": "commands",
   "token": "bot",
   "required": true,
   "justification": "Runs /flarebell, which pages the on-call engineer for the service a user names.",
   "action": "keep"
  },
  {
   "scope": "channels:read",
   "token": "bot",
   "required": true,
   "justification": "Lists public channels in the alert-routing picker during setup so the team can choose where alerts land.",
   "action": "keep"
  },
  {
   "scope": "channels:history",
   "token": "user",
   "required": true,
   "justification": "needs a feature note",
   "action": "drop"
  },
  {
   "scope": "search:read",
   "token": "user",
   "required": true,
   "justification": "Incident search is only planned for next quarter, so no shipped feature uses this scope, and it is ineligible for the Marketplace in any case.",
   "action": "drop"
  }
 ],
 "gate": [
  {
   "rung": "customers",
   "action": "Offer the unlisted Flarebell app to existing customers already asking for Slack alerts until 10 active workspaces† use it within 28 days†."
  },
  {
   "rung": "partners",
   "action": "Recruit design partners for the pilot to close the gap from 6 to 10 active workspaces† if customers fall short."
  },
  {
   "rung": "staff",
   "action": "Use staff or partner teams on their own paid workspaces only as a last resort, never your build, test or sandbox workspaces."
  }
 ],
 "fixes": [
  {
   "check": "install_gate",
   "change": "Stay an unlisted pilot and submit only once 10 active workspaces† have used Flarebell in the last 28 days†, sandboxes excluded."
  },
  {
   "check": "ineligible_scopes",
   "change": "Remove the user-token search:read scope from the manifest; incident search goes through its own review once it ships."
  },
  {
   "check": "enhanced_scopes",
   "change": "Remove the user-token channels:history scope: no shipped feature reads channel history, and the pilot already runs from the app_mention event."
  },
  {
   "check": "test_commands",
   "change": "Remove /alerts-test from the production manifest and keep test commands in a separate dev app only."
  },
  {
   "check": "urls_public",
   "change": "Move the redirect, event, interactivity and slash command URLs from the ngrok tunnel to a stable public HTTPS domain you control."
  }
 ],
 "packet": [
  {
   "item": "Write one justification per scope naming the feature, the user action that triggers it and what the app does with the data.",
   "owner": "Product",
   "done": false
  },
  {
   "item": "Publish a landing page built for the Slack app, a support page that answers within 2 business days†, and a privacy policy that covers Slack data.",
   "owner": "Web team",
   "done": false
  },
  {
   "item": "Keep the self-install link reviewers run themselves in the Testing Information field.",
   "owner": "Engineering",
   "done": true
  },
  {
   "item": "Add test accounts with dummy data on Flarebell.",
   "owner": "Support",
   "done": false
  },
  {
   "item": "Record the demo video.",
   "owner": "Product",
   "done": false
  }
 ],
 "timeline": [
  {
   "step": "Grow the unlisted pilot to the install gate.",
   "wait": "until 10 active workspaces† have used it in the last 28 days†"
  },
  {
   "step": "Submit the production app and clear the automated checks.",
   "wait": "at submission"
  },
  {
   "step": "Preliminary review of the listing, links, scope reasons and install access.",
   "wait": "up to 10 business days†"
  },
  {
   "step": "Functional review: a reviewer installs Flarebell and tests it.",
   "wait": "up to 10 weeks† for a new app"
  },
  {
   "step": "Plan one returned round as another preliminary cycle.",
   "wait": "up to 10 business days†"
  },
  {
   "step": "Publish when you choose after approval.",
   "wait": "your choice"
  }
 ],
 "footnote": "† verify against current Slack Marketplace docs, because Slack changed its install gate twice in a year.",
 "caveats": [
  "Confirm that no pilot feature still reads channel history before removing channels:history.",
  "Confirm the public HTTPS domain before updating every URL in the manifest."
 ]
}

Cut from this display only (the saved reply and the check use the full text): fixes (9 items, 5 shown); packet (7 items, 5 shown).

What step 5 prints for it: install_gate | ineligible_scopes | enhanced_scopes | test_commands | urls_public | test_accounts | display_name | user_token | demo_video

The page's check: kept - nothing to flag

fixes - Incident alerts with an ineligible scope

The input carries the saved plan (R.laneForm fills carry from the plan reply), as the page's handoff does, and the returned feedback, which numbers 4 points from preliminary review.

Input (carry is 1914 characters; the first 1394 are shown; facts is 6692 characters; the first 1398 are shown):

{
 "task": "fixes",
 "app": "App: Flarebell (staging) (bot user: Flarebell)\nStage: unlisted pilot\nActive workspaces in the last 28 days†, sandboxes excluded: 6; weekly active users: 31\nLaunch date: not given; an event or announcement is pinned to it: no\nSends Slack data to an LLM: no; an automation platform connecting other services: no; Enterprise Grid or Slack Connect customers: no\nListed-change kind: none\nReviewer installs: a self-install link: yes; test accounts with dummy data: no; a demo video: no",
 "manifest": "Manifest (YAML), the fields the review reads:\ndisplay_information: name Flarebell (staging); description Incident alerts and on-call acks in Slack; background_color #7a1f1f\nlong_description: Flarebell posts an alert card to the channel your team picks when a monitor fires, pages the on-call engineer with /flarebell, and lets anyone acknowledge the incident from the card. Search past incidents without leaving Slack.\nbot_user: Flarebell; always_online on\nslash_commands: /flarebell (Page the on-call engineer; usage [service] [message]; url https://abcd-1234.ngrok-free.app/slack/commands); /alerts-test (Send a test alert to this channel; url https://abcd-1234.ngrok-free.app/slack/commands)\nshortcuts: none\napp_home: not set\nredirect_urls: https://abcd-1234.ngrok-free.app/slack/oauth\nscopes.bot: chat:write, commands, channels:read\nscopes.bot_optional: none\nscopes.user: channels:history, search:read\nscopes.user_optional: none\nevent_subscriptions: request_url https://abcd-1234.ngrok-free.app/slack/events; bot_events app_mention; user_events none\ninteractivity: on; request_url https://abcd-1234.ngrok-free.app/slack/actions\nsettings: org_deploy_enabled off; socket_mode_enabled off; token_rotation_enabled off",
 "features": "What the app does in Slack:\nWhen a monitor fires, Flarebell posts an alert card to the channel the team picked in setup; /flarebell pages the on-call engineer; anyone in the channel can acknowledge the incident from the card's button. Incident search is planned for next quarter.\n\nFeature notes, one per scope (scope | what the feature does with it):\nchat:write | posts the alert card to the channel picked in /flarebell setup, and nowhere else\ncommands | /flarebell pages the on-call engineer for a service\nchannels:read | lists public channels for the alert-routing picker in setup",
 "feedback": "The returned review feedback:\nSubject: Flarebell - returned during preliminary review\n\nHello Flarebell team,\n\nThank you for submitting Flarebell to the Slack Marketplace. We are returning it during preliminary review for the following reasons:\n\n1. Your redirect URL and request URLs point to a temporary tunnel (abcd-1234.ngrok-free.app). Please host them on a stable, public HTTPS domain you control.\n2. The user scope search:read is not eligible for apps distributed in the Slack Marketplace. Please remove it.\n3. Please remove the /alerts-test slash command - test commands should not ship in the production app.\n4. We could not find test accounts for your service. Please add a test account with dummy data to the Testing Information field so we can complete functional review.\n\nOnce you have addressed these points, please resubmit.\n\nSlack Marketplace Review",
 "carry": "The submission plan (eligibility not_yet), the scope table:\n- chat:write (bot, required): keep - Posts the alert card to the channel the team picked in /flarebell setup when a monitor fires, and posts nowhere else.\n- commands (bot, required): keep - Runs /flarebell, which pages the on-call engineer for the service a user names.\n- channels:read (bot, required): keep - Lists public channels in the alert-routing picker during setup so the team can choose where alerts land.\n- channels:history (user, required): drop - needs a feature note\n- search:read (user, required): drop - Incident search is only planned for next quarter, so no shipped feature uses this scope, and it is ineligible for the Marketplace in any case.\nThe plan's fixes:\n- install_gate: Stay an unlisted pilot and submit only once 10 active workspaces† have used Flarebell in the last 28 days†, sandboxes excluded.\n- ineligible_scopes: Remove the user-token search:read scope from the manifest; incident search goes through its own review once it ships.\n- enhanced_scopes: Remove the user-token channels:history scope: no shipped feature reads channel history, and the pilot already runs from the app_mention event.\n- test_commands: Remove /alerts-test from the production manifest and keep test commands in a separate dev app only.\n- urls_public: Move the redirect, event, interactivity and slash command URLs from the ngrok\n\n[... 520 more characters cut from this display ...]",
 "facts": "The page's checks (6 fail, 4 warn, 8 pass, 0 info, 10 n/a); verdict: returned as it stands; eligibility: not_yet; rule | id | status | evidence:\n- 1 | install_gate | fail | 6 active workspaces - below the 10 active workspaces† gate: Slack blocks the submission below it; pilot as an unlisted distributed app until then\n- 1 | weekly_users | pass | 31 weekly active users\n- 2 | scope_features | warn | no feature note for channels:history (user), search:read (user) - justification needs a feature; a scope for the roadmap returns the submission\n- 2 | optional_scopes | na | no scope marked optional (bot_optional, user_optional)\n- 3 | ineligible_scopes | fail | ineligible: search:read (user) - remove each one; Slack does not list an app that requests it\n- 3 | partner_scopes | pass | no admin.* scope\n- 3 | enhanced_scopes | fail | enhanced review with no feature note while unlisted pilot: channels:history (user) - drop it or justify it with a feature that exists today\n- 3 | user_token | warn | user-token scopes with no note saying the app acts as the user: channels:history (user), search:read (user) - bot token by default; a user token only when the app must act as that user\n- 3 | token_duplicates | pass | no scope on both tokens\n- elig | history_pilot | pass | history scopes (channels:history (user)) with 1 event subscribed - drive pilot features from events; new unlisted installs get\n\n[... 5294 more characters cut from this display ...]"
}

Reply (shortened for display - see the cuts below):

{
 "task": "fixes",
 "summary": "Preliminary review returned four points - tunnel URLs, the ineligible search:read scope, a test command and missing test accounts; one resubmission fixes all four.",
 "points": [
  {
   "n": 1,
   "feedback": "The redirect and request URLs point to a temporary ngrok tunnel.",
   "fix": "Every redirect, event, interactivity and slash command URL now uses a stable public HTTPS domain the team controls.",
   "check": "urls_public"
  },
  {
   "n": 2,
   "feedback": "The user scope search:read is not eligible for Marketplace apps.",
   "fix": "The user-token search:read scope is removed from the manifest, together with the unused user-token channels:history scope.",
   "check": "ineligible_scopes"
  },
  {
   "n": 3,
   "feedback": "Remove the /alerts-test slash command.",
   "fix": "The /alerts-test command is removed from the production app.",
   "check": "test_commands"
  },
  {
   "n": 4,
   "feedback": "No test accounts for the service.",
   "fix": "A Flarebell test account with dummy monitors and incidents is in the Testing Information field.",
   "check": "test_accounts"
  }
 ],
 "resubmit": {
  "stage": "preliminary",
  "queue": "A preliminary resubmission resets your queue position, so every fix goes in one pass.",
  "when": "Resubmit once, after all four fixes are in the manifest and the Testing Information field."
 },
 "notes": "Hello,\n\nThank you for the review. Every change is in this one resubmission:\n\n1. URLs: the redirect, event, interactivity and slash command URLs now use our own public HTTPS domain instead of a temporary tunnel.\n2. Scopes: the user-token search:read scope is [... 293 more characters cut from this display]",
 "footnote": "† verify against current Slack Marketplace docs, because Slack changed its install gate twice in a year.",
 "caveats": [
  "Hold the resubmission until 10 active workspaces† use the app in the last 28 days†; Slack blocks it below the gate."
 ]
}

Cut from this display only (the saved reply and the check use the full text): notes (550 characters, 257 shown).

What step 5 prints for it: 1 urls_public | 2 ineligible_scopes | 3 test_commands | 4 test_accounts

The page's check: kept - nothing to flag

plan - An AI catch-me-up app launching in 30 days

The page read it as: passes with fixes to make; stage returned; eligibility eligible; scopes app_mentions:read (bot), assistant:write (bot), channels:history (bot), channels:read (bot), chat:write (bot), commands (bot), im:history (bot), users:read (bot); to drop: none; gate order partners > customers > staff; fail checks: none.

Input (facts is 7223 characters; the first 1396 are shown):

{
 "task": "plan",
 "app": "App: Driftnote\nStage: returned\nActive workspaces in the last 28 days†, sandboxes excluded: 14; weekly active users: 62\nLaunch date: 2026-12-01 (30 days from today, 2026-11-01); an event or announcement is pinned to it: yes\nSends Slack data to an LLM: yes; an automation platform connecting other services: no; Enterprise Grid or Slack Connect customers: yes\nListed-change kind: none\nReviewer installs: a self-install link: yes; test accounts with dummy data: yes; a demo video: yes",
 "manifest": "Manifest (YAML), the fields the review reads:\ndisplay_information: name Driftnote; description Catch up on the channels you missed; background_color #3b2a6b\nlong_description: Driftnote summarizes the channels and threads a user missed while away, on request, in a direct message or the assistant panel. Summaries are written by a large language model from the messages the user can already see.\nbot_user: Driftnote; always_online on\nslash_commands: /driftnote (Summarize a channel since you last read it; url https://hooks.driftnote.example/slack/commands)\nshortcuts: none\napp_home: home tab on; messages tab on; messages read-only off\nassistant_view: Summaries of what you missed in channels you follow; 1 suggested prompt\nredirect_urls: https://hooks.driftnote.example/slack/oauth\nscopes.bot: app_mentions:read, assistant:write, channels:history, channels:read, chat:write, commands, im:history, users:read\nscopes.bot_optional: none\nscopes.user: none\nscopes.user_optional: none\nevent_subscriptions: request_url https://hooks.driftnote.example/slack/events; bot_events app_mention, assistant_thread_started, message.im; user_events none\ninteractivity: on; request_url https://hooks.driftnote.example/slack/actions\nsettings: org_deploy_enabled off; socket_mode_enabled off; token_rotation_enabled on",
 "features": "What the app does in Slack:\nDriftnote writes a summary of the public channels and threads a user missed, only when the user asks - with /driftnote, by mentioning the bot, or in the assistant panel - from messages that user can already see. Messages are fetched when asked and not stored; summaries are kept for 30 days. Summaries come from a hosted LLM; we never train models on Slack data.\n\nFeature notes, one per scope (scope | what the feature does with it):\napp_mentions:read | hears an @Driftnote mention so a user can ask for a summary in the channel\nassistant:write | shows the summary and a thread status in the assistant panel\nchannels:history | fetches the public-channel messages the user missed, when the user asks, to write the summary; nothing is stored\nchannels:read | lists the channels the user follows to offer them in the assistant panel\nchat:write | posts the summary to the user by DM or in the thread they asked in\ncommands | /driftnote summarizes a channel since the user last read it\nim:history | reads the user's request in the Driftnote DM to know which channel to summarize\nusers:read | resolves user IDs to names inside a summary",
 "feedback": "",
 "carry": "",
 "facts": "The page's checks (0 fail, 4 warn, 16 pass, 1 info, 7 n/a); verdict: passes with fixes to make; eligibility: eligible; rule | id | status | evidence:\n- 1 | install_gate | pass | 14 active workspaces - at or above the gate; keep that count through the whole review\n- 1 | weekly_users | pass | 62 weekly active users\n- 2 | scope_features | pass | a feature note for each of 8 scopes\n- 2 | optional_scopes | na | no scope marked optional (bot_optional, user_optional)\n- 3 | ineligible_scopes | pass | none of identity.*, search:read, read, post, client, workflow.steps:execute or triggers:*\n- 3 | partner_scopes | pass | no admin.* scope\n- 3 | enhanced_scopes | warn | enhanced review: channels:history (bot), im:history (bot) - each needs a justification naming the feature; fetch messages when needed instead of storing copies; Slack rarely approves broad history access for bulk indexing\n- 3 | user_token | pass | every scope is on the bot token\n- 3 | token_duplicates | pass | no scope on both tokens\n- elig | history_pilot | na | not in an unlisted pilot\n- elig | coded_workflow | pass | no Deno SDK runtime, functions or outgoing domains\n- elig | unsuitable | pass | nothing in the description or the manifest matches the unsuitable list\n- packet | test_commands | pass | 1 slash command: /driftnote\n- env | display_name | pass | 'Driftnote'\n- env | urls_public | pass | 4 URLs, all HTTPS on a\n\n[... 5827 more characters cut from this display ...]"
}

Reply (shortened for display - see the cuts below):

{
 "task": "plan",
 "summary": "Driftnote is eligible, but it needs the AI disclosure, org-ready deployment for Enterprise Grid customers and a launch plan, because approval can land after the pinned launch date.",
 "eligibility": {
  "verdict": "eligible",
  "why": "Driftnote has 14 active workspaces, above the 10 active workspaces† gate, and no check fails. Summaries come from a hosted LLM and the team never trains models on Slack data, so it stays off the unsuitable list."
 },
 "scopes": [
  {
   "scope": "app_mentions:read",
   "token": "bot",
   "required": true,
   "justification": "Hears an @Driftnote mention so a user can ask for a summary right in the channel.",
   "action": "keep"
  },
  {
   "scope": "assistant:write",
   "token": "bot",
   "required": true,
   "justification": "Shows the requested summary and a thread status while it is written in the assistant panel.",
   "action": "keep"
  },
  {
   "scope": "channels:history",
   "token": "bot",
   "required": true,
   "justification": "Fetches the public-channel messages a user missed, only when that user asks, to write the summary; no message is stored.",
   "action": "keep"
  },
  {
   "scope": "channels:read",
   "token": "bot",
   "required": true,
   "justification": "Lists the channels a user follows so the assistant panel can offer them for a summary.",
   "action": "keep"
  },
  {
   "scope": "chat:write",
   "token": "bot",
   "required": true,
   "justification": "Posts the summary back to the user by DM or in the thread where it was requested.",
   "action": "keep"
  }
 ],
 "gate": [
  {
   "rung": "partners",
   "action": "Start design-partner recruiting this week so the pilot keeps growing before the pinned launch."
  },
  {
   "rung": "customers",
   "action": "Keep the 14 existing customer workspaces active through the review so the count stays above 10 active workspaces†."
  },
  {
   "rung": "staff",
   "action": "Never count staff, build or sandbox workspaces toward the gate."
  }
 ],
 "fixes": [
  {
   "check": "ai_disclosure",
   "change": "Say on the listing and the landing page that AI summaries can be inaccurate, and answer the AI fields: the model, retention, data tenancy and residency."
  },
  {
   "check": "org_deploy",
   "change": "Turn on org-ready deployment and test it and Slack Connect channels on a Grid test org before resubmitting."
  },
  {
   "check": "enhanced_scopes",
   "change": "Keep channels:history and im:history with justifications that say messages are fetched on request and never stored."
  },
  {
   "check": "timeline",
   "change": "Announce Driftnote as coming to the Slack Marketplace on launch day, take pilot sign-ups, and publish on approval."
  }
 ],
 "packet": [
  {
   "item": "Add the AI disclosure to the listing and the landing page and answer the AI fields.",
   "owner": "Product and legal",
   "done": false
  },
  {
   "item": "State in the privacy policy how long summaries are kept and how a customer requests deletion.",
   "owner": "Legal",
   "done": false
  },
  {
   "item": "Show a thread status in the assistant panel while a summary is written.",
   "owner": "Engineering",
   "done": false
  },
  {
   "item": "Test org-ready deployment and Slack Connect behaviour.",
   "owner": "Engineering",
   "done": false
  },
  {
   "item": "Keep the self-install link, test accounts and video in the Testing Information field.",
   "owner": "Support",
   "done": true
  }
 ],
 "timeline": [
  {
   "step": "Fix every point in one pass and resubmit.",
   "wait": "one resubmission"
  },
  {
   "step": "Functional review: a reviewer installs Driftnote and tests it again.",
   "wait": "up to 10 weeks† for a new app"
  },
  {
   "step": "Allow one more returned round, counted as another preliminary cycle.",
   "wait": "up to 10 business days†"
  },
  {
   "step": "Launch day: announce Driftnote as coming to the Slack Marketplace and take pilot sign-ups.",
   "wait": "on the pinned date, likely before approval"
  },
  {
   "step": "Publish when approval lands.",
   "wait": "on approval"
  }
 ],
 "footnote": "† verify against current Slack Marketplace docs, because Slack changed its install gate twice in a year.",
 "caveats": [
  "Token rotation is on: refresh within 12 hours† and persist each new refresh token.",
  "Confirm the 30-day summary retention matches the Security & Compliance answers."
 ]
}

Cut from this display only (the saved reply and the check use the full text): scopes (8 items, 5 shown).

What step 5 prints for it: ai_disclosure | org_deploy | enhanced_scopes | timeline

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./slkit.js"); require("./slreply.js")), then:

const K = SlKit, R = SlReply;
const f = K.form(Object.assign(K.blank(), { stage: "pilot", workspaces: "4", install: "yes", accounts: "no",
  manifest: "display_information:\n  name: Acme Alerts\noauth_config:\n  scopes:\n    bot: [chat:write, commands]\n    user: [search:read]",
  what: "Posts an alert card to the channel the team picks; /acme pages the on-call engineer." }));
// f.parsed (format, error with its line), f.m (the manifest), f.scopes (the scope table), f.rows (the checks),
// f.unsuitable, f.gate (the install-gate menu), f.timeline, f.plan (the stage's plan)
// K.scopesCsv(f), K.checksCsv(f), K.envNote(f), K.board(f) - the exports
const b = K.buildInput(f, "plan");                           // or "fixes" (needs feedback; R.laneForm(form, "fixes", planReply) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "plan");
// R.check(reply, b)                                         // {status: kept or flagged, notes, fixed}

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://slack-submit.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"slack-submit"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "plan", "app": "App: Flarebell (staging) (bot user: Flarebell)
Stage: unlisted pilot
...", "manifest": "Manifest (YAML), the fields the review reads:
...", "features": "What the app does in Slack:
...", "feedback": "", "carry": "", "facts": "The page's checks (...); verdict: returned as it stands; eligibility: not_yet; rule | id | status | evidence:
- 1 | install_gate | fail | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses slack-submit:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A plan reply carries summary, eligibility, scopes, gate, fixes, packet, timeline, footnote and caveats; a fixes-lane reply carries summary, points, resubmit, notes, footnote and caveats. The code below prints the gist of either: each fix of the plan, or each feedback point with its fix.

KEY="slack-submit:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "fixes" then (.points[] | "\(.n). \(.feedback) -> \(.fix)") 
       else (.fixes[] | "\(.check) \(.change)") end' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: slack-submit:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json