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.
| task | stage | lane | what it returns | body keys |
|---|---|---|---|---|
review | 1 Inspect | 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. | scorecard, structure_map, quick_wins |
state | 1 Inspect | 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. | state_model, edits |
perf | 2 Decide | 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. | cache_plan, edits |
data | 2 Decide | 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. | display_plan, edits |
layout | 2 Decide | Lay out the page | A region plan (sidebar, columns, tabs, containers, expanders) and a plain-text wireframe of the finished page, plus quoted edits. | layout_plan, wireframe, edits |
pages | 3 Produce | 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. | 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
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | One of review, state, perf, data, layout, pages. |
script | string | yes | The 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_version | string | no | current (1.40+), 1.30, 1.20 or unknown. Decides which API the answer is allowed to assume. |
deploy_target | string | no | community, internal, snowflake or local. |
notes | string | no | Anything 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_facts | object | no | The 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
| status | code | what to do |
|---|---|---|
401 | unauthorized | The token is missing, malformed or expired. Mint a new one with POST /guest. |
402 | payment_required | The balance cannot cover this run's minimum. Only /run and /run-stream can return it - /estimate is free. |
404 | not_found | Wrong path, or a job id that does not belong to this app's token. |
422 | VALIDATION_ERROR | The input object failed validation. error.details names the field. |
429 | rate_limited | Too many requests. Back off; do not retry in a tight loop. |
500 | internal_error | Retry 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.import requests
BASE = "https://api.skillsafe.ai/v1/app-api"
def guest_token():
r = requests.post(BASE + "/guest", json={"slug": "streamlit-desk"}, timeout=30)
r.raise_for_status()
return r.json()["data"]["token"]
TOKEN = guest_token() # or paste one from /tokens.html
HEAD = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}
def call(method, path, body=None, headers=None):
h = dict(HEAD)
h.update(headers or {})
r = requests.request(method, BASE + path, json=body, headers=h, timeout=180)
payload = r.json()
if not payload.get("ok"):
raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
return payload["data"]const BASE = "https://api.skillsafe.ai/v1/app-api";
async function guestToken() {
const r = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "streamlit-desk" })
});
const p = await r.json();
if (!p.ok) throw new Error(p.error.code + ": " + p.error.message);
return p.data.token;
}
const TOKEN = await guestToken(); // or paste one from /tokens.html
async function call(method, path, body, extraHeaders) {
const r = await fetch(BASE + path, {
method,
headers: Object.assign({
Authorization: "Bearer " + TOKEN,
"Content-Type": "application/json"
}, extraHeaders || {}),
body: body ? JSON.stringify(body) : undefined
});
const p = await r.json();
if (!p.ok) throw new Error(p.error.code + ": " + p.error.message);
return p.data;
}package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
)
const Base = "https://api.skillsafe.ai/v1/app-api"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func guestToken() (string, error) {
body, _ := json.Marshal(map[string]string{"slug": "streamlit-desk"})
res, err := http.Post(Base+"/guest", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return "", err
}
if !env.OK {
return "", errors.New(env.Error.Code + ": " + env.Error.Message)
}
var d struct {
Token string `json:"token"`
}
json.Unmarshal(env.Data, &d)
return d.Token, nil
}import java.net.URI;
import java.net.http.*;
public class StreamlitDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String token;
static String guestToken() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"streamlit-desk\"}"))
.build();
String body = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// Parse with your JSON library of choice and read data.token
return extract(body, "token");
}
}require "net/http"
require "json"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
def guest_token
uri = URI(BASE + "/guest")
res = Net::HTTP.post(uri, { slug: "streamlit-desk" }.to_json,
"Content-Type" => "application/json")
payload = JSON.parse(res.body)
raise "#{payload["error"]["code"]}: #{payload["error"]["message"]}" unless payload["ok"]
payload["data"]["token"]
end
TOKEN = guest_token # or paste one from /tokens.html<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
function post_json(string $path, array $body, array $headers = []): array {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_merge(["Content-Type: application/json"], $headers),
CURLOPT_POSTFIELDS => json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
$token = post_json("/guest", ["slug" => "streamlit-desk"])["token"];
$auth = ["Authorization: Bearer " . $token];using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
var http = new HttpClient();
async Task<string> GuestTokenAsync() {
var body = new StringContent("{\"slug\":\"streamlit-desk\"}",
Encoding.UTF8, "application/json");
var res = await http.PostAsync(Base + "/guest", body);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean())
throw new Exception(doc.RootElement.GetProperty("error").GetProperty("code").GetString());
return doc.RootElement.GetProperty("data").GetProperty("token").GetString()!;
}
var token = await GuestTokenAsync(); // or paste one from /tokens.html
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);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" } } }me = call("GET", "/me")
print(me["subject_type"], me["credits"])const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);req, _ := http.NewRequest("GET", Base+"/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()HttpRequest me = HttpRequest.newBuilder(URI.create(BASE + "/me"))
.header("Authorization", "Bearer " + token)
.GET().build();
System.out.println(HTTP.send(me, HttpResponse.BodyHandlers.ofString()).body());uri = URI(BASE + "/me")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{TOKEN}")
me = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
puts me["subject_type"], me["credits"]$ch = curl_init(BASE . "/me");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $auth]);
$me = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;var meRes = await http.GetStringAsync(Base + "/me");
using var meDoc = JsonDocument.Parse(meRes);
var me = meDoc.RootElement.GetProperty("data");
Console.WriteLine(me.GetProperty("credits").GetInt32());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`.SCRIPT = open("app.py", encoding="utf-8").read()
def payload(task, **extra):
body = {
"task": task,
"script": SCRIPT[:26000],
"st_version": "current",
"deploy_target": "internal",
"notes": "",
"prescan_facts": {"looks_streamlit": True, "flags": []},
}
body.update(extra)
return body
est = call("POST", "/estimate", payload("review"))
print(est["model"], est["model_alias"], est["hold_credits"], "reserved")
assert est["model_alias"] == "gpt-terra"const script = await (await fetch("/app.py")).text();
const payload = (task, extra = {}) => Object.assign({
task,
script: script.slice(0, 26000),
st_version: "current",
deploy_target: "internal",
notes: "",
prescan_facts: { looks_streamlit: true, flags: [] }
}, extra);
const est = await call("POST", "/estimate", payload("review"));
console.log(est.model_alias, est.hold_credits, "reserved");body, _ := json.Marshal(map[string]any{
"task": "review",
"script": script,
"st_version": "current",
"deploy_target": "internal",
"prescan_facts": map[string]any{"looks_streamlit": true, "flags": []any{}},
})
req, _ := http.NewRequest("POST", Base+"/estimate", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()String body = """
{"task":"review","script":"...","st_version":"current",
"deploy_target":"internal",
"prescan_facts":{"looks_streamlit":true,"flags":[]}}
""";
HttpRequest est = HttpRequest.newBuilder(URI.create(BASE + "/estimate"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
System.out.println(HTTP.send(est, HttpResponse.BodyHandlers.ofString()).body());def payload(task, script)
{ task: task, script: script[0, 26_000], st_version: "current",
deploy_target: "internal", notes: "",
prescan_facts: { looks_streamlit: true, flags: [] } }
end
uri = URI(BASE + "/estimate")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{TOKEN}",
"Content-Type" => "application/json")
req.body = payload("review", File.read("app.py")).to_json
est = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
puts est["hold_credits"]function payload(string $task, string $script): array {
return [
"task" => $task,
"script" => substr($script, 0, 26000),
"st_version" => "current",
"deploy_target" => "internal",
"notes" => "",
"prescan_facts" => ["looks_streamlit" => true, "flags" => []],
];
}
$est = post_json("/estimate", payload("review", file_get_contents("app.py")), $auth);
echo $est["model_alias"], " reserves ", $est["hold_credits"], PHP_EOL;object Payload(string task, string script) => new {
task,
script = script.Length > 26000 ? script[..26000] : script,
st_version = "current",
deploy_target = "internal",
notes = "",
prescan_facts = new { looks_streamlit = true, flags = Array.Empty<object>() }
};
var estBody = new StringContent(JsonSerializer.Serialize(Payload("review", script)),
Encoding.UTF8, "application/json");
var estRes = await http.PostAsync(Base + "/estimate", estBody);
Console.WriteLine(await estRes.Content.ReadAsStringAsync());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.import hashlib, json, time
def idem(task, script, attempt=1):
h = hashlib.sha256(script.encode()).hexdigest()[:16]
return f"streamlit-desk:{task}:{h}:a{attempt}"
def run(task, **extra):
body = payload(task, **extra)
job = call("POST", "/run", body,
{"Idempotency-Key": idem(task, body["script"])})
while True:
state = call("GET", "/jobs/" + job["job_id"])
if state["status"] != "running":
break
time.sleep(2)
if state["status"] != "succeeded":
raise RuntimeError(state.get("error") or state["status"])
# The model returns one JSON object as text.
return json.loads(state["output"]["output"]), state
result, meta = run("review")
print(result["verdict"], len(result["findings"]), "findings")
print("charged", meta.get("charged_credits"), "credits")import { createHash } from "node:crypto";
const idem = (task, script, attempt = 1) =>
`streamlit-desk:${task}:${createHash("sha256").update(script).digest("hex").slice(0, 16)}:a${attempt}`;
async function run(task, extra = {}) {
const body = payload(task, extra);
const job = await call("POST", "/run", body,
{ "Idempotency-Key": idem(task, body.script) });
let state;
do {
state = await call("GET", "/jobs/" + job.job_id);
if (state.status === "running") await new Promise(r => setTimeout(r, 2000));
} while (state.status === "running");
if (state.status !== "succeeded") throw new Error(state.error || state.status);
return [JSON.parse(state.output.output), state];
}
const [result, meta] = await run("review");
console.log(result.verdict, result.findings.length, "findings");sum := sha256.Sum256([]byte(script))
key := fmt.Sprintf("streamlit-desk:review:%x:a1", sum[:8])
req, _ = http.NewRequest("POST", Base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ = http.DefaultClient.Do(req)
// read data.job_id, then GET /jobs/{id} until status != "running"String key = "streamlit-desk:review:" + sha256(script).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
String jobId = extract(HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body(), "job_id");
// then poll GET /jobs/{jobId} until status != "running"require "digest"
def idem(task, script, attempt = 1)
"streamlit-desk:#{task}:#{Digest::SHA256.hexdigest(script)[0, 16]}:a#{attempt}"
end
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri,
"Authorization" => "Bearer #{TOKEN}",
"Content-Type" => "application/json",
"Idempotency-Key" => idem("review", script))
req.body = payload("review", script).to_json
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
# then GET /jobs/#{job["job_id"]} until status != "running"function idem(string $task, string $script, int $attempt = 1): string {
return "streamlit-desk:$task:" . substr(hash("sha256", $script), 0, 16) . ":a$attempt";
}
$job = post_json("/run", payload("review", $script),
array_merge($auth, ["Idempotency-Key: " . idem("review", $script)]));
// then GET /jobs/{$job["job_id"]} until status != "running"string Idem(string task, string script, int attempt = 1) {
var hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(script)));
return $"streamlit-desk:{task}:{hash[..16].ToLowerInvariant()}:a{attempt}";
}
var runReq = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
Content = new StringContent(JsonSerializer.Serialize(Payload("review", script)),
Encoding.UTF8, "application/json")
};
runReq.Headers.Add("Idempotency-Key", Idem("review", script));
var runRes = await http.SendAsync(runReq);
// then GET /jobs/{job_id} until status != "running"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.import httpx
def run_stream(task, **extra):
body = payload(task, **extra)
raw = []
headers = dict(HEAD)
headers["Idempotency-Key"] = idem(task, body["script"])
with httpx.stream("POST", BASE + "/run-stream", json=body,
headers=headers, timeout=300) as r:
for line in r.iter_lines():
if line.startswith("data: "):
event = json.loads(line[6:])
if "text" in event:
raw.append(event["text"])
return "".join(raw)
text = run_stream("pages")
result = json.loads(text[text.index("{"):text.rindex("}") + 1])const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
Authorization: "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": idem("pages", script)
},
body: JSON.stringify(payload("pages"))
});
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let raw = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
for (const line of value.split("\n")) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
if (event.text) raw += event.text;
}
}
const result = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));req, _ = http.NewRequest("POST", Base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
scanner := bufio.NewScanner(res.Body)
var sb strings.Builder
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
var ev struct{ Text string `json:"text"` }
json.Unmarshal([]byte(line[6:]), &ev)
sb.WriteString(ev.Text)
}
}HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
StringBuilder raw = new StringBuilder();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body()
.filter(line -> line.startsWith("data: "))
.forEach(line -> raw.append(textField(line.substring(6))));uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri,
"Authorization" => "Bearer #{TOKEN}",
"Content-Type" => "application/json",
"Idempotency-Key" => idem("pages", script))
req.body = payload("pages", script).to_json
raw = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data: ")
event = JSON.parse(line[6..])
raw << event["text"] if event["text"]
end
end
end
end$ch = curl_init(BASE . "/run-stream");
$raw = "";
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => array_merge($auth, [
"Content-Type: application/json",
"Idempotency-Key: " . idem("pages", $script),
]),
CURLOPT_POSTFIELDS => json_encode(payload("pages", $script)),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data: ")) {
$event = json_decode(substr($line, 6), true);
if (isset($event["text"])) { $raw .= $event["text"]; }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);var streamReq = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = new StringContent(JsonSerializer.Serialize(Payload("pages", script)),
Encoding.UTF8, "application/json")
};
streamReq.Headers.Add("Idempotency-Key", Idem("pages", script));
var streamRes = await http.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
while (await reader.ReadLineAsync() is { } line) {
if (!line.StartsWith("data: ")) continue;
using var ev = JsonDocument.Parse(line[6..]);
if (ev.RootElement.TryGetProperty("text", out var t)) raw.Append(t.GetString());
}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.