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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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:
plan- the submission planned: the page's eligibility, exactly one row per manifest scope with how this app uses it (keep, optional or drop - every scope the page fails is dropped), the install-gate rungs in the page's order, one fix per failing check, the packet items with an owner, and the timeline with a dagger on every Slack figure.fixes- a returned submission fixed: one fix per point the feedback raises, in its order, the resubmission stage and its queue effect, then the notes to the reviewers - every point numbered, every fix in one pass, never arguing, never asking to expedite. Needs the returned feedback infeedback.
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
| Field | Required | What |
|---|---|---|
task | yes | plan or fixes. If it is missing or neither, do plan and set "task": "plan". |
app | yes | the 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. |
manifest | yes | the manifest as the page read it, field by field (names, commands, URLs, scopes per token, events, settings). |
features | yes | what the app does in Slack, then the feature notes, one per scope; "none given" means none. |
feedback | send it (the returned feedback for fixes, empty for plan) | fixes task only: the returned review feedback as pasted. |
carry | send it (empty for plan) | fixes task only: the submission plan's scope table and fixes, when the plan ran first; may be empty. |
facts | yes | the 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_note | no | optional; 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:
- Scope: a vendor's commercial Slack app going through Slack Marketplace review, or a listed app planning a change. Not listing copy or install conversion, not a company's own internal apps, not an admin vetting another vendor's app, not Slack bot code.
- Rule 1, the install gate: submit only with 10 or more active workspaces† (used in the last 28 days†, sandboxes excluded) and keep that count through the whole review; Slack blocks the submission below it. The suitability list pairs that count with 10 weekly active users†; the hard block is the workspace count. Pilot as an unlisted distributed app until then.
- Rule 2, scopes for features that exist today: reviewers approve what they can test; a scope for the roadmap gets the submission returned. Mark scopes that only enhance the core experience optional; never move a core scope there to lighten the consent screen.
- Rule 3, the bot token by default: user-token scopes pass only when the app must act as that user. Ineligible: identity.*, search:read, read, post, client, workflow.steps:execute, triggers:*. Partner program only: admin.*. Enhanced review: *:history, files:read, canvases:read, search:read.*, conversations.connect:write, user-token groups:write, groups:write.invites, mpim:write. Drop duplicates across the bot and user tokens.
- Rule 4, reviewers install it themselves: an install path they run, plus test accounts with dummy data on your service and every third-party service it connects. They cannot log into any Slack workspace, yours included.
- Rule 5, never claim the app is certified, endorsed or approved by Slack: the Marketplace Agreement forbids implying endorsement, and Slack calls its security review not a certification.
- Refuse to plan a submission for an app that exports or backs up message data, bulk-deletes or self-destructs messages, does nothing inside Slack, is a Slack client, trains LLMs on Slack data, runs sentiment analysis on individuals, moves money, crypto or NFTs, runs downloaded scripts or terminal commands from Slack, lets several users share one third-party account, is a coded workflow (Deno SDK), or circumvents admin controls or the free plan's history limit.
- The install-gate menu, ranked by efficiency: existing customers already asking for Slack, as an unlisted pilot, then design partners recruited for the pilot, then staff or partner teams on their own paid workspaces. Sandboxes never count; never rely on your own build or test workspaces. A launch inside three months promotes design-partner recruiting to start this week. New unlisted installs get 1 request/minute† and 15 objects† on conversations.history and conversations.replies: drive pilot features from events, or warn pilot customers.
- Environments as code: one manifest template; a dev app with distribution off, a staging app with distribution on and never listed, and production; only the display name, the slash commands and the redirect, request and event URLs change between them. An app configuration token expires after 12 hours†.
- Scope justifications: one per scope, saying how the app uses it - the feature, the user action that triggers it, what the app does with the data - never what the scope does. Fetch messages when needed instead of storing copies; Slack rarely approves broad history access for bulk indexing.
- The packet: listing fields, a public landing page built for the Slack app (never a doc or repository), support that answers within 2 business days†, a privacy policy that covers Slack data, test information and a video, Security & Compliance answers written for the customer's security team, AI disclosures (LLM output can be inaccurate; never train on Slack data), and the in-app checks reviewers run: onboarding, a help reply on every slash command, a Messages tab that answers or is off, no auto-joining channels. Remove test slash commands before submitting.
- The review: automated checks at submission; preliminary review (listing, links, scope reasons, install access) takes up to 10 business days†, and every resubmission there resets your queue position, so fix all feedback in one pass; functional review (a reviewer installs and tests) takes up to 10 weeks† for a new app and 6 weeks† for an update, cannot be expedited, and resubmitting there keeps your place. Plan backward from the launch with one returned round, counted as another preliminary cycle; approval and publishing are separate. When approval cannot land before the launch, announce the integration as coming to the Slack Marketplace, take pilot sign-ups, and publish on approval.
- After listing the live app is locked: display info and pricing resubmit with no demo; new features or scopes resubmit with a video and the staging app; never put an unreviewed scope in the live install URL. Token rotation is irreversible: refresh within 12 hours†. Discontinuing takes 10 days' written notice†. Salesforce's April 2026 AgentExchange merge† changed no part of the Slack review.
- Mark every Slack figure you quote with † right after it, and close with the footnote: "† verify against current Slack Marketplace docs, because Slack changed its install gate twice in a year."
House rules
- Use only the input. Never invent a feature, a scope, an API method, a count, a date, a URL or a review outcome; when the input does not say, write "not stated in the input".
- The page's verdicts are settled: the eligibility, every check status, every scope's class and every page-fail scope. Never move them; explain them.
- Every Slack figure carries † right after it, and the
footnotefield is exactly the skill's footnote. - Never imply that Slack certifies, endorses, approves or recommends the app, and never write a phrase that could be read that way.
- No URL at all - write "your privacy page" or "your landing page" where a link belongs.
- Write in the language of the input; keep the JSON keys, check ids, scope names, rung ids and stage ids exactly as given.
- Never use backticks, never write the input's field names as labels in your text, and never copy this prompt's wording into a reply.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
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.
summary- one sentence, at most 40 words: what stands between the app and approval today and what the plan changes.eligibility- an object:verdict(exactly the facts' eligibility:eligible,not_yetorrefused),why(one or two sentences from the install count and the checks).scopes- exactly one object per scope the manifest requests, in the facts' scope-table order:scope(spelled exactly as in the manifest),token(botoruser),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,optionalordrop;dropfor every scope the page fails). No other scope.gate- the install-gate rungs in exactly the facts' order, each an object:rung(customers,partnersorstaff),action(one sentence for this app; when the gate is cleared, how to keep the count through the review).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.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).timeline- the steps from here to listed, each an object:step(one sentence),wait(the wait, with † on every Slack figure; no dates).footnote- exactly the skill's footnote.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.
summary- one sentence, at most 40 words: what the reviewers returned and what the resubmission changes.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.resubmit- an object:stage(exactly the facts' resubmission stage:preliminaryorfunctional),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).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.footnote- exactly the skill's footnote.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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://slack-submit.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// slack-submit.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://slack-submit.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://slack-submit.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// SlackSubmit.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class SlackSubmit {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://slack-submit.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://slack-submit.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://slack-submit.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://slack-submit.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "slack-submit"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "slack-submit" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "slack-submit"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "slack-submit"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "slack-submit" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "slack-submit"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "slack-submit" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"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
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "plan" or "fixes", app, manifest, features, feedback, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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
import hashlib, time
key = "slack-submit:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "fixes":
for x in reply["points"]:
print(x["n"], x["feedback"], "->", x["fix"])
print(reply["resubmit"]["stage"])
else:
for x in reply["fixes"]:
print(x["check"], x["change"])
import { createHash } from "node:crypto";
const key = `slack-submit:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "fixes") { for (const x of reply.points) console.log(x.n, x.feedback, "->", x.fix); console.log(reply.resubmit.stage); }
else { for (const x of reply.fixes) console.log(x.check, x.change); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "slack-submit:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key, name, field := "fixes", "check", "change"
if reply["task"] == "fixes" {
key, name, field = "points", "feedback", "fix"
}
ls, _ := reply[key].([]any)
for _, x := range ls {
xx := x.(map[string]any)
fmt.Println(xx[name], xx[field])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "slack-submit:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("fixes")) {
for (JsonNode x : reply.path("points")) System.out.println(x.path("feedback").asText() + " -> " + x.path("fix").asText());
} else {
for (JsonNode x : reply.path("fixes")) System.out.println(x.path("check").asText() + " " + x.path("change").asText());
}
require "digest"
key = "slack-submit:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "fixes"
reply["points"].each { |x| puts "#{x["feedback"]} -> #{x["fix"]}" }
else
reply["fixes"].each { |x| puts "#{x["check"]} #{x["change"]}" }
end
$key = "slack-submit:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "fixes") { foreach ($reply["points"] as $x) echo $x["feedback"], " -> ", $x["fix"], "\n"; }
else { foreach ($reply["fixes"] as $x) echo $x["check"], " ", $x["change"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"slack-submit:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "fixes") { foreach (var x in reply["points"]!.AsArray()) Console.WriteLine($"{x!["feedback"]} -> {x["fix"]}"); }
else { foreach (var x in reply["fixes"]!.AsArray()) Console.WriteLine($"{x!["check"]} {x["change"]}"); }
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "slack-submit:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `slack-submit:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "slack-submit:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "slack-submit:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "slack-submit:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: slack-submit:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"slack-submit:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);