← Slack Desk / API
Tokens

Drive Slack Desk from your own code

Everything the page does is available over HTTP. The natural uses are a CI job that lints every Slack message template in a repository before release, a bot that turns a changelog entry into a Block Kit payload, and a review step that refuses to post a payload the validator rejects.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

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

Send your app slug as X-App-Slug: slack-desk and your token as Authorization: Bearer … on every call.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Check the X-App-Slug header.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422A required field is missing or the wrong type. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

The task field comes first

Slack Desk is one app with four lanes over one work object. Every request must carry a task; it selects the lane, and it changes both the cost and the shape of the output. If task is missing the model picks the closest lane and names its choice in ## SUMMARY — useful as a fallback, useless as a contract. Send it.

taskstagewhat you sendwhat you get back
reviewinspecta message, or a payloadLANE, SUMMARY, VERDICT, FINDINGS, REWRITE, CHECKS
composeproduceraw notes or a draftLANE, SUMMARY, MESSAGE, SHORT, THREAD, WHY, CHECKS
blocksproducethe message textLANE, SUMMARY, PAYLOAD, FALLBACK, WHY, CHECKS
shipdeliverthe payloadLANE, SUMMARY, PAYLOAD, PYTHON, JAVASCRIPT, WEBHOOK, HANDLER, CHECKS

The input object

Taken from the app's own buildInput(), not from intent:

fieldtypelanesmeaning
taskstringallreview | compose | blocks | ship
messagestringallThe work object: message text, or a Block Kit payload as JSON text. Clipped at 24 000 characters, head and tail kept, with a marker.
kindstringallmessage or payload. The browser decides this; send what you have.
channelstringallteam | company | incident | customer | dm
purposestringallannounce | request | update | decide | escalate
tonestringallplain | warm | formal | urgent
prescanstringallThe client-side facts block. Optional over HTTP, but the model reconciles against it, so omitting it costs you the ## CHECKS section's value.
interactivitystringblocks, shipnone | ack | approve | select
runtimestringshipboth | python | js
notesstringreview, composeAnything the model should know about the audience. Optional.
clippedstringallPresent only when the work object was too large to send whole. For message text the middle is dropped and both ends are kept; for a Block Kit payload whole blocks are dropped from the middle so what arrives is still valid JSON. When present, the model says so in ## SUMMARY and in ## CHECKS, and its answer covers only what it saw. Omit it and the model assumes it received the entire input.

A complete request body:

{
  "task": "blocks",
  "kind": "message",
  "message": "Shipping v4.2 at 16:00 UTC. Billing moves to /account/billing.",
  "channel": "company",
  "purpose": "announce",
  "tone": "plain",
  "interactivity": "ack",
  "prescan": "CLIENT PRESCAN (computed in the browser, treat as ground truth):\n- message text: 63 characters over 1 line(s)\n- lint: 0 blocker(s), 0 warning(s), 0 nit(s)"
}

1. Get a token

The shortest path is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. A guest token can call /me and /estimate; running a lane is metered and needs a personal token.

# The token page hands you a ready-made shell export:
#   https://slack-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# Or mint a guest token here. A guest can call /me and /estimate;
# running a lane needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "X-App-Slug: slack-desk"
# {"ok":true,"data":{"token":"sk_guest_...","subject_type":"guest"}}

2. Check the balance

/me is free and tells you who the token belongs to and what it can spend. Compare credits against the hold_credits from the next step before you run, so a 402 never surprises you after submit.

curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
  -H "X-App-Slug: slack-desk" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}

3. Price the run for free

/estimate takes the same body as /run, costs nothing and starts no job. Estimate the lane you are about to run: the four lanes have different prompts and different output caps, so hold_credits differs per lane. hold_credits is what gets reserved, not what you pay; the settled charge is usually far lower.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "X-App-Slug: slack-desk" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "blocks", "kind": "message", "message": "Shipping v4.2 at 16:00 UTC. Billing moves to /account/billing.", "channel": "company", "purpose": "announce", "tone": "plain", "interactivity": "ack", "prescan": "CLIENT PRESCAN (computed in the browser, treat as ground truth):\n- message text: 63 characters over 1 line(s)\n- lint: 0 blocker(s), 0 warning(s), 0 nit(s)"}'
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#                    "markup_bps":1000,"hold_credits":4180,"min_credits":320}}

4. Run it and poll

/run returns a job_id; poll /jobs/{id} until it reaches a terminal state. Always send an Idempotency-Key derived from the task, the input and an attempt counter — a retry with the same key is not billed twice, and the same input under two different tasks must use two different keys.

# One key per (task, input, attempt). Two lanes over one message are two runs.
KEY="sd-blocks-$(printf '%s' "$MESSAGE" | shasum | cut -c1-8)-1"

JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "X-App-Slug: slack-desk" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
  -d '{"task": "blocks", "kind": "message", "message": "Shipping v4.2 at 16:00 UTC. Billing moves to /account/billing.", "channel": "company", "purpose": "announce", "tone": "plain", "interactivity": "ack", "prescan": "CLIENT PRESCAN (computed in the browser, treat as ground truth):\n- message text: 63 characters over 1 line(s)\n- lint: 0 blocker(s), 0 warning(s), 0 nit(s)"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')

until [ "$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
  -H "X-App-Slug: slack-desk" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')" = "succeeded" ]; do
  sleep 2
done

5. Stream it instead

/run-stream is the same call over SSE. The deltas arrive as the model writes, so the ## section headings are what a progress indicator should key off. If the stream dies mid-way, the text you already have still parses: sections are independent, and the app reports “N of M sections recovered” rather than discarding the run.

curl -sS -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "X-App-Slug: slack-desk" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d '{"task": "blocks", "kind": "message", "message": "Shipping v4.2 at 16:00 UTC. Billing moves to /account/billing.", "channel": "company", "purpose": "announce", "tone": "plain", "interactivity": "ack", "prescan": "CLIENT PRESCAN (computed in the browser, treat as ground truth):\n- message text: 63 characters over 1 line(s)\n- lint: 0 blocker(s), 0 warning(s), 0 nit(s)"}'
# data: {"type":"delta","text":"## LANE\nblocks\n\n## SUMMARY\n..."}
# data: {"type":"done","job_id":"job_..."}

The output contract

Every lane returns plain text with ## section headings — one envelope, four bodies. The app splits on ^##[ \t]+([A-Z][A-Z_]*)[ \t]*$, so a heading must be uppercase, on its own line, with nothing after it. Sections are independent, which is what makes a truncated stream still useful.

sectionlanesform
LANEallthe lane id, lowercase, on one line
SUMMARYallone to three sentences of prose
VERDICTreviewpost-it | fix-first | rewrite, then one sentence
FINDINGSreviewbullets: [blocker|warn|nit] title :: what the reader sees :: the fix
REWRITEreviewa ```text fence
MESSAGE, SHORT, THREADcompose```text fences; THREAD may be None.
PAYLOADblocks, shipa ```json fence: one object with blocks and text
FALLBACKblocksa ```text fence matching the payload's text
PYTHON, JAVASCRIPT, WEBHOOK, HANDLERshipfences tagged python, javascript, bash, python
WHYcompose, blocksthree to six bullets
CHECKSallbullets, one per prescan finding, naming the rule id

A worked example per lane

Same message in every case — “Shipping v4.2 at 16:00 UTC. Billing moves to /account/billing.” — with only task changed.

task: review

## LANE
review

## SUMMARY
The message says the right thing but posts three Markdown constructs that Slack renders literally.

## VERDICT
fix-first
The content is correct; the formatting is what will embarrass you in a company channel.

## FINDINGS
- [blocker] Markdown bold :: readers see the asterisks, nothing is bold :: use *v4.2*
- [warn] No owner named :: nobody knows who to ask at 16:00 :: add the on-call handle

## REWRITE
```text
Shipping *v4.2* at 16:00 UTC. Billing moves to /account/billing.
```

## CHECKS
- MD-BOLD line 1: fixed - **v4.2** became *v4.2*.

task: compose

## LANE
compose

## SUMMARY
Led with the change and the time, and moved the bookmark detail into the thread.

## MESSAGE
```text
*v4.2 ships at 16:00 UTC today.* Billing moves to /account/billing.
```

## SHORT
```text
v4.2 at 16:00 UTC - billing moves to /account/billing.
```

## THREAD
```text
Old bookmarks keep redirecting for 30 days.
```

## WHY
- Put the time in the first clause because the purpose is an announcement in a company channel.

## CHECKS
- Prescan reported no findings; nothing to reconcile.

task: blocks

## LANE
blocks

## SUMMARY
A header, one section and an acknowledge button, with a one-line notification fallback.

## PAYLOAD
```json
{
  "text": "v4.2 ships at 16:00 UTC; billing moves to /account/billing.",
  "blocks": [
    { "type": "header", "text": { "type": "plain_text", "text": "v4.2 ships at 16:00 UTC" } },
    { "type": "section", "text": { "type": "mrkdwn", "text": "Billing moves to `/account/billing`." } },
    { "type": "actions", "elements": [
      { "type": "button", "action_id": "ack_v42", "style": "primary",
        "text": { "type": "plain_text", "text": "Acknowledged" } }
    ] }
  ]
}
```

## FALLBACK
```text
v4.2 ships at 16:00 UTC; billing moves to /account/billing.
```

## WHY
- The header carries the subject so the message is scannable in a busy channel.

## CHECKS
- Prescan reported no findings; nothing to reconcile.

task: ship

## LANE
ship

## SUMMARY
Bolt for Python and for JavaScript post the same payload; one handler acknowledges the button.

## PAYLOAD
```json
{ "text": "v4.2 ships at 16:00 UTC", "blocks": [ ... as above ... ] }
```

## PYTHON
```python
from slack_bolt import App

app = App(token="SLACK_BOT_TOKEN_PLACEHOLDER")  # load this from your secret manager
app.client.chat_postMessage(channel="#launch", text=TEXT, blocks=BLOCKS)
```

## JAVASCRIPT
```javascript
const { App } = require("@slack/bolt");
const app = new App({ token: "SLACK_BOT_TOKEN_PLACEHOLDER" });
await app.client.chat.postMessage({ channel: "#launch", text: TEXT, blocks: BLOCKS });
```

## WEBHOOK
```bash
curl -X POST -H 'Content-Type: application/json' \
  --data @payload.json \
  https://hooks.slack.com/services/T000/B000/XXXX
```

## HANDLER
```python
@app.action("ack_v42")
def ack_v42(ack, body, respond):
    ack()
    respond(text="Acknowledged by <@" + body["user"]["id"] + ">")
```

## CHECKS
- Prescan reported no findings; nothing to reconcile.

Validate the payload before you post it

The app never displays a payload it could not parse and validate, and neither should your pipeline. The cheapest gate is: parse the PAYLOAD fence with a real JSON parser, then assert blocks.length <= 50, every header is plain_text and at most 150 characters, every section text is at most 3000, every button label is at most 75, and no two elements share an action_id. A payload that fails any of those is rejected by Slack at post time, which is the worst place to find out.