← Streamlit Desk / API
Manage your token

Drive Streamlit Desk from your own code

Everything the web app does over the network, you can do yourself — run the review lane over every app.py in a repository, or wire the pages lane into a refactoring script. Base URL:

https://api.skillsafe.ai/v1/app-api

Every response is the same envelope. On success you get data; on failure you get error, and never both.

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

Note the paths. Only POST /guest ever names the app slug. Every other endpoint identifies the app from the token you send, so there is no /estimate or /run path with a slug in it.

The task field comes first

Streamlit Desk is one app with six lanes over one work object — your Streamlit script. Every request carries an explicit task, and the reply answers that lane and only that lane. Two lanes over the same script are two distinct runs and must use two distinct idempotency keys.

taskstagelanewhat it returnsbody keys
review1 InspectReview the scriptSix area scores out of five, plus a structure map assigning every line of the file to a named unit and a proposed filename, plus the quick wins.scorecard, structure_map, quick_wins
state1 InspectFix the state modelA per-key session_state model - owner, initial value, where it is written and read, whether it survives a rerun, and the risk - plus quoted edits.state_model, edits
perf2 DecidePlan the cachingA caching plan naming the decorator per target, the TTL, and the parameters that must be excluded from the hash, plus quoted edits.cache_plan, edits
data2 DecideChoose the data elementsThe right display element for each thing on the page with the column_config to go with it, plus quoted edits.display_plan, edits
layout2 DecideLay out the pageA region plan (sidebar, columns, tabs, containers, expanders) and a plain-text wireframe of the finished page, plus quoted edits.layout_plan, wireframe, edits
pages3 ProduceSplit it into pagesThe complete files of a multipage app with the shared loader lifted out and cached once, the navigation block, and quoted edits.pages, files, nav_code, edits

An absent or unrecognised task does not error: the model picks the closest lane and names the lane it chose in the reply's own task field, so you can detect the fallback rather than being silently handed the wrong contract.

Input contract

fieldtyperequiredmeaning
taskstringyesOne of review, state, perf, data, layout, pages.
scriptstringyesThe Streamlit script, as plain Python source. Clip to about 26,000 characters; the web app cuts whole lines out of the middle, keeps both ends and announces the cut in-band so the model knows a hole is there.
st_versionstringnocurrent (1.40+), 1.30, 1.20 or unknown. Decides which API the answer is allowed to assume.
deploy_targetstringnocommunity, internal, snowflake or local.
notesstringnoAnything the script does not say. A handoff from another lane puts that lane's conclusions here, and the prompt treats them as established context rather than re-deriving them.
prescan_factsobjectnoThe browser linter's output. Its flags[] each carry an id, and the model must reconcile every one of them in coverage_check. Send {"looks_streamlit": true, "flags": []} if you have no linter of your own.

The notes field is also the handoff channel: when the web app moves you from one lane to the next it writes the finished lane's conclusions there, and the prompt treats them as established context instead of re-deriving them.

Output contract

One JSON object. These keys are present in every lane:

{
  "task": "review",
  "title": "string - a short name for the script",
  "verdict": "ship | tune | restructure",
  "headline": "string - the one sentence a person reads first",
  "summary": "string - two to four sentences",
  "findings": [
    {
      "id": "F-001",
      "severity": "critical | high | medium | low",
      "area": "structure | state | performance | data | layout | pages",
      "flag": "SD-T01",          // the linter flag this answers, or ""
      "title": "string",
      "detail": "string",
      "fix": "string",
      "lines": [12, 14],         // line numbers in YOUR paste, 1-based
      "code": "corrected Python, or \"\""
    }
  ],
  "coverage_check": [
    { "flag_id": "SD-T01", "status": "confirmed | adjusted | set-aside", "note": "string" }
  ],
  "assumptions": ["string"],
  "open_questions": ["string"],
  "next_step": "string"
}

Then exactly one lane body, and no other lane's body:

task: "review" — Review the script

"scorecard": [
  { "area": "structure", "score": 3, "status": "pass | partial | fail", "note": "..." }
  // all six areas, in the order structure, state, performance, data, layout, pages
],
"structure_map": [
  {
    "unit": "load_sales",
    "kind": "load | transform | render | control | state | helper",
    "lines": [18, 24],
    "belongs_in": "data.py",
    "why": "..."
  }
],
"quick_wins": ["phrased as an instruction, not an observation"]

task: "state" — Fix the state model

"state_model": [
  {
    "key": "region",
    "owner": "widget | script | callback",
    "initial": "how it is initialised, or how it should be",
    "written_at": [36],
    "read_at": [41, 52],
    "survives_rerun": true,
    "risk": "none | keyerror | widget-clash | stale | leak",
    "note": "..."
  }
],
"edits": [
  {
    "title": "short name for the change",
    "before": "the exact lines from your paste, copied verbatim",
    "after": "what they become",
    "lines": [40, 44],
    "why": "one sentence"
  }
]

task: "perf" — Plan the caching

"cache_plan": [
  {
    "target": "load_sales",
    "lines": [18, 24],
    "decorator": "st.cache_data | st.cache_resource | st.fragment | none",
    "ttl": "3600",
    "exclude_params": ["_conn"],
    "current_cost": "what it costs on every rerun today",
    "after": "what it costs once this is in place",
    "why": "..."
  }
],
"edits": [
  {
    "title": "short name for the change",
    "before": "the exact lines from your paste, copied verbatim",
    "after": "what they become",
    "lines": [40, 44],
    "why": "one sentence"
  }
]

task: "data" — Choose the data elements

"display_plan": [
  {
    "what": "the sales table",
    "lines": [40],
    "current": "st.write(df)",
    "use": "st.dataframe",
    "config": "use_container_width=True, hide_index=True, column_config={...}",
    "why": "..."
  }
],
"edits": [
  {
    "title": "short name for the change",
    "before": "the exact lines from your paste, copied verbatim",
    "after": "what they become",
    "lines": [40, 44],
    "why": "one sentence"
  }
]

task: "layout" — Lay out the page

"layout_plan": [
  {
    "region": "sidebar | main | tabs | columns | expander | container | popover",
    "holds": ["what goes here, named from your paste"],
    "lines": [30, 38],
    "why": "..."
  }
],
"wireframe": "an indented plain-text sketch of the finished page, 25 lines at most",
"edits": [
  {
    "title": "short name for the change",
    "before": "the exact lines from your paste, copied verbatim",
    "after": "what they become",
    "lines": [40, 44],
    "why": "one sentence"
  }
]

task: "pages" — Split it into pages

"pages": [
  {
    "path": "pages/1_Sales.py",
    "title": "Sales",
    "icon": ":material/bar_chart:",
    "holds": ["the units that move here"],
    "from_lines": [40, 78],
    "why": "..."
  }
],
"files": [
  { "path": "app.py", "purpose": "...", "code": "the COMPLETE contents of this file" }
],
"nav_code": "the st.navigation / st.Page block, or \"\" for the pages/ convention",
"edits": [
  {
    "title": "short name for the change",
    "before": "the exact lines from your paste, copied verbatim",
    "after": "what they become",
    "lines": [40, 44],
    "why": "one sentence"
  }
]

Error codes

statuscodewhat to do
401unauthorizedThe token is missing, malformed or expired. Mint a new one with POST /guest.
402payment_requiredThe balance cannot cover this run's minimum. Only /run and /run-stream can return it - /estimate is free.
404not_foundWrong path, or a job id that does not belong to this app's token.
422VALIDATION_ERRORThe input object failed validation. error.details names the field.
429rate_limitedToo many requests. Back off; do not retry in a tight loop.
500internal_errorRetry once with the SAME Idempotency-Key. A retry with the same key cannot double-bill.

1. Get a token

A guest token is one call and needs no account; it spends the app's guest wallet, which is off by default, so a guest can estimate but not run. For real runs use a personal token — the token page shows yours, masked, with a copy button and a shell export. Never paste a token into a page you did not write.

# A guest token. This is the ONLY call that names the slug - every later
# call identifies the app from the token itself.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H 'Content-Type: application/json' \
  -d '{"slug":"streamlit-desk"}'

# -> { "ok": true, "data": { "token": "aut_...", "guest_id": "gst_..." } }
# Keep the token. A personal token from /tokens.html works the same way and
# spends your own credits instead of a guest wallet.

2. Check who you are and what you can afford

GET /me is free and tells you whether the token is a guest or a person, and what the balance is. Compare it against min_credits from the estimate before you run: a 402 after submitting is a failure of your client, not of the user.

curl -s https://api.skillsafe.ai/v1/app-api/me -H "Authorization: Bearer YOUR_TOKEN"

# -> { "ok": true, "data": {
#        "subject_type": "guest",      # or "user" once signed in
#        "credits": 48210,
#        "app": { "slug": "streamlit-desk", "model_alias": "gpt-terra" } } }

3. Estimate — free, no job created

POST /estimate takes the same input object as /run and returns the price without creating a job or billing anything. It is also the cheapest proof that your input shape is valid and that the app is bound to the model you expect: assert model_alias reads gpt-terra and markup_bps is 1000.

hold_credits is a reservation priced at the full output cap, not the price. The real charge, returned as charged_credits when the job settles, is usually far lower. It differs per lane, so re-estimate whenever you change task.

# Free. No job is created and nothing is billed.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"task": "review","script": "import streamlit as st\nimport pandas as pd\nst.title(\"Sales\")\nst.set_page_config(layout=\"centered\")\ndf = pd.read_csv(\"sales.csv\")\nregion = st.selectbox(\"Region\", [\"EU\", \"US\"])\nst.write(df[df.region == region])\nst.write(\"Total: \" + str(st.session_state.total))","st_version": "current","deploy_target": "internal","notes": "","prescan_facts": { "looks_streamlit": true, "flags": [] }}'

# -> { "ok": true, "data": {
#        "model": "gpt-5.6-terra", "model_alias": "gpt-terra",
#        "markup_bps": 1000, "hold_credits": 3140, "min_credits": 420,
#        "sponsor_enabled": false } }
#
# hold_credits is a RESERVATION priced at the full output cap, not the price.
# It differs per lane, so re-estimate whenever you change `task`.

4. Run and poll

POST /run returns a job_id; poll GET /jobs/{id} until status leaves running. The reply text is at data.output.output and is the JSON object described above, as a string — parse it from the first { to the last } so a stray code fence cannot break you.

Always send an Idempotency-Key, and put the lane in it. Hash (task, script, attempt). Two lanes over the same script that share a key will hand you the first lane's reply for both; a retry that reuses the key cannot double-bill.

# The Idempotency-Key must include the LANE. Two lanes over the same script
# are two distinct runs; reusing one key across them returns the first reply.
KEY="streamlit-desk:review:$(shasum -a 256 app.py | cut -c1-16):a1"

JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @input.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll to a terminal state.
until [ "$(curl -s https://api.skillsafe.ai/v1/app-api/jobs/$JOB -H "Authorization: Bearer YOUR_TOKEN" \
       | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')" != "running" ]; do
  sleep 2
done

curl -s https://api.skillsafe.ai/v1/app-api/jobs/$JOB -H "Authorization: Bearer YOUR_TOKEN"
# -> data.output.output holds the JSON object described above, as a string.

5. Stream it instead

POST /run-stream is the same call over server-sent events. The pages lane in particular writes whole files and can run for a while, so streaming is what lets you show progress against the output contract rather than a spinner. If the stream dies mid-flight, parse what you have — the web app renders the sections that arrived and says how many of them there were.

curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: streamlit-desk:pages:abc123:a1" \
  -d @input.json

# Server-sent events:
#   event: job    data: {"job_id":"job_..."}
#   event: delta  data: {"text":"{\"task\":\"pages\","}
#   ...
#   event: done   data: {"status":"succeeded","charged_credits":2870,"truncated":false}
#
# Concatenate every delta's `text` to rebuild the JSON object. If the stream
# dies mid-flight, whatever you have is still worth parsing - the web app
# renders the sections that arrived rather than discarding them.

6. One worked example per lane

The same eight-line script, run through each lane in turn. It is deliberately broken:

import streamlit as st
import pandas as pd
st.title("Sales")
st.set_page_config(layout="centered")
df = pd.read_csv("sales.csv")
region = st.selectbox("Region", ["EU", "US"])
st.write(df[df.region == region])
st.write("Total: " + str(st.session_state.total))

task: "review" — Review the script

Six area scores out of five, plus a structure map assigning every line of the file to a named unit and a proposed filename, plus the quick wins.

{
  "task": "review",
  "script": "import streamlit as st\n... (the script above)",
  "st_version": "current",
  "deploy_target": "internal",
  "notes": "",
  "prescan_facts": {
    "looks_streamlit": true,
    "flags": [
      {
        "id": "SD-S02",
        "severity": "critical",
        "area": "structure",
        "title": "st.set_page_config is not the first Streamlit command"
      },
      {
        "id": "SD-T01",
        "severity": "critical",
        "area": "state",
        "title": "session_state read before anything puts it there"
      }
    ]
  }
}

The reply carries the envelope plus scorecard, structure_map, quick_wins. Its coverage_check must hold exactly two entries here — one for SD-S02 and one for SD-T01 — because that is what the linter sent.

task: "state" — Fix the state model

A per-key session_state model - owner, initial value, where it is written and read, whether it survives a rerun, and the risk - plus quoted edits.

{
  "task": "state",
  "script": "import streamlit as st\n... (the script above)",
  "st_version": "current",
  "deploy_target": "internal",
  "notes": "",
  "prescan_facts": {
    "looks_streamlit": true,
    "flags": [
      {
        "id": "SD-S02",
        "severity": "critical",
        "area": "structure",
        "title": "st.set_page_config is not the first Streamlit command"
      },
      {
        "id": "SD-T01",
        "severity": "critical",
        "area": "state",
        "title": "session_state read before anything puts it there"
      }
    ]
  }
}

The reply carries the envelope plus state_model, edits. Its coverage_check must hold exactly two entries here — one for SD-S02 and one for SD-T01 — because that is what the linter sent.

task: "perf" — Plan the caching

A caching plan naming the decorator per target, the TTL, and the parameters that must be excluded from the hash, plus quoted edits.

{
  "task": "perf",
  "script": "import streamlit as st\n... (the script above)",
  "st_version": "current",
  "deploy_target": "internal",
  "notes": "",
  "prescan_facts": {
    "looks_streamlit": true,
    "flags": [
      {
        "id": "SD-S02",
        "severity": "critical",
        "area": "structure",
        "title": "st.set_page_config is not the first Streamlit command"
      },
      {
        "id": "SD-T01",
        "severity": "critical",
        "area": "state",
        "title": "session_state read before anything puts it there"
      }
    ]
  }
}

The reply carries the envelope plus cache_plan, edits. Its coverage_check must hold exactly two entries here — one for SD-S02 and one for SD-T01 — because that is what the linter sent.

task: "data" — Choose the data elements

The right display element for each thing on the page with the column_config to go with it, plus quoted edits.

{
  "task": "data",
  "script": "import streamlit as st\n... (the script above)",
  "st_version": "current",
  "deploy_target": "internal",
  "notes": "",
  "prescan_facts": {
    "looks_streamlit": true,
    "flags": [
      {
        "id": "SD-S02",
        "severity": "critical",
        "area": "structure",
        "title": "st.set_page_config is not the first Streamlit command"
      },
      {
        "id": "SD-T01",
        "severity": "critical",
        "area": "state",
        "title": "session_state read before anything puts it there"
      }
    ]
  }
}

The reply carries the envelope plus display_plan, edits. Its coverage_check must hold exactly two entries here — one for SD-S02 and one for SD-T01 — because that is what the linter sent.

task: "layout" — Lay out the page

A region plan (sidebar, columns, tabs, containers, expanders) and a plain-text wireframe of the finished page, plus quoted edits.

{
  "task": "layout",
  "script": "import streamlit as st\n... (the script above)",
  "st_version": "current",
  "deploy_target": "internal",
  "notes": "",
  "prescan_facts": {
    "looks_streamlit": true,
    "flags": [
      {
        "id": "SD-S02",
        "severity": "critical",
        "area": "structure",
        "title": "st.set_page_config is not the first Streamlit command"
      },
      {
        "id": "SD-T01",
        "severity": "critical",
        "area": "state",
        "title": "session_state read before anything puts it there"
      }
    ]
  }
}

The reply carries the envelope plus layout_plan, wireframe, edits. Its coverage_check must hold exactly two entries here — one for SD-S02 and one for SD-T01 — because that is what the linter sent.

task: "pages" — Split it into pages

The complete files of a multipage app with the shared loader lifted out and cached once, the navigation block, and quoted edits.

{
  "task": "pages",
  "script": "import streamlit as st\n... (the script above)",
  "st_version": "current",
  "deploy_target": "internal",
  "notes": "",
  "prescan_facts": {
    "looks_streamlit": true,
    "flags": [
      {
        "id": "SD-S02",
        "severity": "critical",
        "area": "structure",
        "title": "st.set_page_config is not the first Streamlit command"
      },
      {
        "id": "SD-T01",
        "severity": "critical",
        "area": "state",
        "title": "session_state read before anything puts it there"
      }
    ]
  }
}

The reply carries the envelope plus pages, files, nav_code, edits. Its coverage_check must hold exactly two entries here — one for SD-S02 and one for SD-T01 — because that is what the linter sent.

Reconciliation, and why it matters

prescan_facts is not decoration. It is the browser linter's output, and the prompt requires the model to return exactly one coverage_check entry per flag id it was given — confirmed, adjusted or set-aside, each with a note. A flag outside the requested lane's area still gets an entry, usually set-aside naming the lane that owns it.

That gives you a cheap, mechanical quality check on every reply: compare the set of ids you sent against the set that came back. Anything missing is the model quietly ignoring evidence it was handed; anything extra is an id it invented. The web app renders both cases explicitly rather than hiding them, and so should yours.

If you have no linter of your own, send {"looks_streamlit": true, "flags": []} and the reply's coverage_check will be empty. The lane still works — it just has nothing to be held to.

Rate limits and cost

Shared platform limits apply: roughly 120 requests a minute across the data endpoints. /estimate, /me and /guest are free; only /run and /run-stream are billed, at the model's token rates plus this app's 10% publisher markup. A run that never reaches the model is not billed at all.

If the balance sits between min_credits and hold_credits, the run still executes with a reduced output cap and the terminal event carries "truncated": true. Treat that as "the answer is incomplete", not as a failure — what arrived is real, there is just less of it.