Burst Desk / API
Get a token

Driving Burst Desk from code

Everything the web app does is available over HTTP. The base URL is https://api.skillsafe.ai/v1/app-api, every request carries Authorization: Bearer <token>, and every response is the same envelope.

The task field comes first

This app has five lanes behind one endpoint. Every run body must carry a task field naming the lane - it is what the system prompt routes on. Send the wrong one and you get a valid package of the wrong kind; omit it and the model picks the closest lane and tells you which it chose.

One more shape trap: the run body is the input object. Do not wrap it in an {"input": ...} envelope - that returns 200 while hiding task from the model, which is the most confusing way this API can fail.

taskLaneFieldsSections returned
planTurn an API and its traffic into a sheetbrief, knownSummary, The Sheet, What It Assumes, Reasoning, Next Step
readWhat each limit admits, as opposed to what it sayssheet, worrySummary, Verdict, Findings, Corrected Sheet, Next Step
edgesThe edges: the boundary, the burst, and the exact referencesheetSummary, Every Rule Against The Exact Reference, Where The Extra Requests Come From, The Test That Finds Each One, Next Step
keysThe keys: what the multiplication puts in front of the backendsheetSummary, Every Rule Times Its Keys, What The Backend Sees, What Would Actually Bound It, Next Step
decideDecide what changes: the algorithm, the number, or the callersheet, fixedSummary, A Different Algorithm Closes, A Different Number Closes, Only The Caller Closes, Nothing Closes - The Property Is The Point, Next Step

Only task and the lane’s own required fields are mandatory: sheet on read, edges, keys and decide; brief on plan. Every field is a string - there are no number fields on this app. sheet is the limit sheet itself: a header of KEY: value lines, then a LIMITS: block with one rule per line.

The header is where a rule becomes a verdict. BACKEND: is what the service behind the limits can take, as a rate - 800/s. Without it the response can compute every figure and conclude nothing. KEYS: is how many distinct keys exist, and it is the multiplier on every per-key rule: a limit of 100/min is either 1.67/s or 13,333/s depending on it. CLIENTS: takes retry, drop or queue and decides whether the rejects come back. GATE: names the one rule that fronts ALL the traffic, if there is one. WINDOW: and SERVICE: are defaults for rules that give a bare count or omit a service time.

A rule needs a name and an algorithm and then a limit= - except debounce and throttle, which need delay= instead. The algorithm word takes the aliases a real config uses: fixed/fixed-window, sliding (the log, and the only exact one), counter/sliding-window-counter, bucket/token-bucket, leaky/gcra, concurrency/in-flight/semaphore, debounce and throttle. Anything else is reported as an unknown algorithm rather than guessed at.

limit= takes 100/min, 10/s, 5/15min or a bare count. A bare count is a COUNT, not a rate - the window comes from window=, the WINDOW: header or the default, and the response says which, because guessing per-second where the config means per-minute is a sixtyfold error in the direction nobody checks. burst= is a bucket’s capacity; leave it off and it defaults to the limit, which is what every implementation does and is why the burst gets forgotten. scope= takes key, ip, global, user, tenant, endpoint, account or token, and everything except global multiplies. service= turns a concurrency limit into a rate; without it the row is undefined and is left out of every total. offered= is the traffic that arrives - without it the response says what a rule admits but not what it rejects. shape= takes steady, bursty, spiky, front or back, and decides which way a sliding counter’s error points.

Everything in the response is one comparison: what a rule SAYS against what it ADMITS. A fixed window admits exactly twice its limit at the boundary - two adjacent counters, not a margin to tune. A token bucket admits B + floor(r × elapsed), so its capacity arrives in one instant. A sliding counter is an estimate that errs both ways by up to the limit. A concurrency limit admits c/S per second. A debounce is a step at the delay. Only a sliding window log admits exactly what it says.

And the keys are the largest number on the page. A limit of N per key with K keys admits N × K, which is what the service sees. A globally-scoped rule bounds its own route; only a rule declared with GATE: bounds all the traffic, and that declaration is your claim rather than something this API can verify.

Anything the reader cannot place is listed as a problem rather than skipped: an unknown header key or an unreadable header value, a line outside a block, a rule with no algorithm or an unknown one, a missing or unreadable limit, an unknown field, extra words on the line and a duplicate name, each with its line number. A sheet with no readable rules is an error.

Rates come back in whichever unit reads best - 13,333/s, 1.67/min - counts as integers, gaps as multiples like . These are ceilings, not descriptions of your traffic: no load is generated and no gateway is read, so the worst window is what the algorithm permits rather than what your callers do, and offered= is one number where real traffic has a shape.

Add $model to any body to choose the model for that run: gpt-5.6-luna, gpt-5.6-terra (the default) or gpt-5.6-sol. Luna caps output at 4,096 tokens and will fail the read, edges, keys and decide lanes rather than shorten them - a findings table, a corrected sheet, or three tables with a row per rule, is several thousand characters before the reasoning starts.

The response envelope

Success and failure have the same outer shape, so one check covers both.

{
  "ok": true,
  "data": {
    "...": "the result"
  }
}
{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "seconds should be number, got string",
    "details": {}
  }
}
HTTPerror.codeWhat it means
400VALIDATION_ERRORThe body was not a JSON object, or a declared field had the wrong type. A number field sent as a string is the usual cause.
401UNAUTHORIZEDNo token, or a token that has expired or been revoked. Mint a new one.
402INSUFFICIENT_CREDITSThe balance is below the run's minimum. Call /estimate first and compare hold_credits against /me.
404NOT_FOUNDWrong path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replay whose body differs from the original request.
429RATE_LIMITEDToo many requests. Back off; do not tight-loop.
503UPSTREAM_UNAVAILABLEThe model provider is unavailable. Retry with backoff.

1. Get a token

Open /tokens.html in a browser and copy the token this app already holds - no developer console needed. A guest token is minted automatically and is enough for /me and /estimate; writing a package is metered and needs a personal token, which comes from signing in on that page.

Keep it in an environment variable rather than in source:

export SKILLSAFE_TOKEN="YOUR_TOKEN"

2. Check the session and the balance

GET /me is free. It returns only three fields: subject_type, subject_id and credits. Signed-in means subject_type == "user" - there is no username or email to test.

curl -sS -X GET "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN"

3. Price the run before making it

POST /estimate costs nothing, creates no job, and returns the worst-case cost. Compare hold_credits against the balance from step 2 before you submit: a 402 after the fact is avoidable. hold_credits is a reservation priced at the full output cap - the actual charge is usually far lower.

It also echoes model, model_alias and markup_bps, which is the authoritative check that a run is bound to the model you think it is. Estimate each lane separately: their prompts and caps differ, so their holds do.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "task": "read",
  "sheet": "<BACKEND/KEYS/CLIENTS/GATE header, then a LIMITS block with one rule per line; the grammar is in /llms.txt>",
  "worry": "we get a spike on the hour that the limits should have stopped",
  "rules": "<the working rules for this lane, sent by the app>"
}'

4. Write a package

POST /run submits the job. Always send an Idempotency-Key: a network blip that replays the same request must not bill twice. A replay with the same key returns the stored result and is not charged again; a replay with the same key but a different body is a 409.

The response carries output.output (the Markdown package), charged_credits and truncated. If truncated is true the balance sat between min_credits and hold_credits and the output was cut short - render what arrived and say so rather than presenting it as complete.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "task": "read",
  "sheet": "<BACKEND/KEYS/CLIENTS/GATE header, then a LIMITS block with one rule per line; the grammar is in /llms.txt>",
  "worry": "we get a spike on the hour that the limits should have stopped",
  "rules": "<the working rules for this lane, sent by the app>"
}'

5. Stream a run

POST /run-stream is the same call with a text/event-stream response. Worth knowing before you build on it: from a server or from cURL you get event: delta frames carrying the output token by token; from a browser you get event: tick heartbeats and then one event: done with the whole output. Handle both, and treat ticks as liveness rather than progress.

Frame types are job (the job id), delta ({"text": "..."}), tick ({"t": seconds}), done, and error. An idempotent replay returns plain JSON with no stream at all, so check the content type before you start reading frames.

curl -sS -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: cbd-$(date +%s)" \
  -d '{
  "task": "read",
  "sheet": "<BACKEND/KEYS/CLIENTS/GATE header, then a LIMITS block with one rule per line; the grammar is in /llms.txt>",
  "worry": "we get a spike on the hour that the limits should have stopped",
  "rules": "<the working rules for this lane, sent by the app>"
}'

6. Read the result

output.output is Markdown in the envelope this app's system prompt guarantees: every section is a level-two heading spelled exactly as listed in the lane table above, in that order; tables are GitHub pipe tables with the declared columns; prompts are in fenced blocks opened with three backticks and the word text; checklists are - [x] lines.

So parsing is a split on /^## / - but do it fence-aware, because a prompt block can legitimately contain a line starting with ##. Count the sections you got against the ones the lane declares: a short list means the run was truncated, not that the contract changed.

def sections(md):
    out, name, buf, fence = {}, None, [], False
    for line in md.split("\n"):
        if line.lstrip().startswith("```"):
            fence = not fence
        if not fence and line.startswith("## "):
            if name:
                out[name] = "\n".join(buf).strip()
            name, buf = line[3:].strip(), []
            continue
        if name:
            buf.append(line)
    if name:
        out[name] = "\n".join(buf).strip()
    return out

The artifact most callers want is the fenced text block inside ## The Sheet or ## Corrected Sheet - that is a complete sheet in the grammar above, so it can be fed straight back into another lane with nothing carried alongside it. Every other section is prose and tables meant to be read.

Rate limits and good manners