Drive Shader Desk from your own code
Everything the web app does is one HTTP API away. Base URL:
https://api.skillsafe.ai/v1/app-api, scoped to this app by the token you send.
Every response is wrapped in an envelope: success is {"ok":true,"data":{…}}, failure is
{"ok":false,"error":{"code":"…","message":"…","details":{…}}}.
Read the envelope, not the HTTP status, for anything the app itself decided: a job that ran and
then failed still arrives inside {"ok":true,"data":{…}} with a terminal
status. The status line matters for the transport errors — 401, 402, 403, 429.
The request body for /run, /run-stream and /estimate
is the input object itself. There is no input wrapper and no
X-App-Slug header — the slug is carried by the token. This is the single most common
way to waste a credit here, so it is spelled out again in
the input contract.
Error codes you will actually meet
| Code | HTTP | What it means | What to do |
|---|---|---|---|
UNAUTHORIZED | 401 | Missing, malformed or expired token. | Mint a new one from the token panel. |
FORBIDDEN | 403 | The token belongs to another app, or it is a guest token on a metered route. | Use an account token minted for shader-desk. Guests cannot run. |
INSUFFICIENT_CREDITS | 402 | Balance is under min_credits for this run. | Compare /me against /estimate before running. |
VALIDATION_ERROR | 400 | The input object is the wrong shape. | Check error.details; task and shader are required. |
NOT_FOUND | 404 | No such route, or no such job_id for this subject. | Check the path and that the job was created by this token. |
RATE_LIMITED | 429 | Too many requests. | Back off; do not tight-loop a poll. |
INTERNAL | 500 | Something broke on our side. | Retry with the same idempotency key. It will not double-bill. |
A guest token can look, not run. POST /v1/app-api/guest mints an
anonymous subject that can call /me and /estimate — enough to price a
lane and show a caller what the contract is. A /run or /run-stream is
metered, and this app does not sponsor guest usage, so a guest run comes back
403 FORBIDDEN. Use a personal token from the token panel for anything that produces a
review. The in-browser reader — the parse, the flags and the real compile — is free and needs no
token at all.
The task field comes first
This app has four lanes over one work object: a single GLSL shader source.
task selects the lane and is the field to get right before any other — it decides the
checks you get back, which lane block is present, and the price. An unrecognised value is not an
error: the model picks the closest lane, names that choice in overview, and sets
lane to what it picked. It never blends two lanes' contracts into one object. So
always read lane back rather than assuming the one you asked for.
task | Lane | Answers | Lane block | Source skill |
|---|---|---|---|---|
diagnose | Diagnose | Why will this shader not compile, and what is the patched source? | repair{} | @sickn33/shader-programming-glsl |
explain | Explain | What is the maths doing, line by line, and which numbers can I turn? | walkthrough{} | @bbeierle12/shader-fundamentals + @minimax-ai/shader-dev |
optimize | Optimize | What does this cost per pixel, and what can be cut without changing the image? | budget{} | @minimax-ai/shader-dev |
port | Port | What does it take to run this in a three.js ShaderMaterial? | port{} | @cloudai-x/threejs-shaders |
One worked example per lane
Each request body below is the whole body — copy the shape, not just the fields. The
shader value is elided here; in a real call it is the whole GLSL source as one JSON
string, newlines and all. Only task and shader are required; every other
field may be omitted or sent empty.
task: "diagnose" — why will it not compile?
Request body:
{"task":"diagnose","shader":"#version 300 es\nvarying vec2 vUv;\nuniform sampler2D uTexture;\n…\n gl_FragColor = vec4(col, 1.0);\n}","vertex":"","target":"webgl2","symptom":"The compiler gives me four errors I do not understand and the material renders solid black.","carryover":"","facts":"BROWSER-MEASURED FACTS …\n- REAL COMPILE in this browser (WebGL2): FAILED with 4 diagnostic(s)\n line 5: 'varying' : syntax error\n\nFLAGS RAISED (5):\n GX-008 [high] line 5 — varying / attribute under #version 300 es: …\n GX-010 [high] line 24 — texture2D() under #version 300 es: …\n GX-007 [high] line 38 — gl_FragColor under #version 300 es: …"}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "fixable",
"repair": {"ready": true,
"fixes": [{"line": 5, "problem": "varying was removed in GLSL ES 3.00",
"change": "in vec2 vUv;", "confidence": "certain"},
{"line": 24, "problem": "texture2D() is the 1.00 spelling",
"change": "vec4 src = texture(uTexture, uv);", "confidence": "certain"},
{"line": 20, "problem": "1/3 is integer division and evaluates to 0",
"change": "float slice = 1.0 / 3.0;", "confidence": "likely"}],
"patched_source": "#version 300 es\nprecision highp float;\nin vec2 vUv;\n…",
"still_open": ["uUnusedGain is declared and never read; deleting it is a host-side decision"],
"note": "Only the language-forced fixes are in patched_source; slice is unused, so its value is a guess."} }
task: "explain" — what is the maths doing?
Request body:
{"task":"explain","shader":"float sdSphere(vec3 p, float r) {\n return length(p) - r;\n}\n…\nvoid mainImage( out vec4 fragColor, in vec2 fragCoord ) {\n…\n}","vertex":"","target":"shadertoy","symptom":"I can run it but I do not understand the loop or the normal function.","carryover":"","facts":"BROWSER-MEASURED FACTS …\n- interface: 0 uniforms (0 samplers), plus host-injected iTime/iResolution\n- per-pixel cost: 0 texture fetches, 14 transcendental calls, 2 branches, 1 loops …\n\nFLAGS RAISED (3):\n GX-026 [info] line - — Shadertoy uniforms are supplied by the host, not by this source: …\n GX-027 [info] line - — mainImage() needs a main() wrapper outside Shadertoy: …\n GX-014 [medium] line 37 — A 256-iteration loop runs per pixel: …"}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "dense",
"walkthrough": {"technique": "sphere-traced SDF raymarching with a wobbling ball over a slab",
"one_liner": "It shoots one ray per pixel into a distance field and shades whatever it hits.",
"sections": [{"lines": "5-11", "title": "Primitive distance functions",
"what": "sdSphere and sdBox return the signed distance to each shape",
"math": "length(p) - r is negative inside the sphere and zero on its surface"},
{"lines": "31-40", "title": "The march",
"what": "steps t forward by the distance to the nearest surface, 256 times",
"math": "the distance field guarantees the step cannot overshoot"}],
"uniforms": [{"name": "iTime", "type": "float", "means": "seconds since start",
"typical": "0.0 upwards, host-driven"}],
"knobs": [{"name": "256", "line": 37, "effect": "how far and how finely the ray marches",
"try": "96 with an early break"}],
"note": "iChannel inputs are not used, so nothing here depends on a texture."} }
task: "optimize" — what does it cost per pixel?
Request body:
{"task":"optimize","shader":"float sdSphere(vec3 p, float r) {\n return length(p) - r;\n}\n…","vertex":"","target":"shadertoy","symptom":"It drops to 20fps in fullscreen on my laptop.","carryover":"Previous lane: explain. Verdict: dense. The 256-step march is the whole cost.","facts":"BROWSER-MEASURED FACTS …\n- per-pixel cost: 0 texture fetches, 14 transcendental calls, 2 branches, 1 loops (deepest nesting 1, largest static bound 256), tier heavy\n- transcendental breakdown: sin x1, cos x1, pow x3, normalize x4, length x5\n\nFLAGS RAISED (3):\n GX-014 [medium] line 37 — A 256-iteration loop runs per pixel: …\n GX-020 [low] line 52 — pow() with a small integer exponent: …\n GX-021 [low] line 61 — length() compared against a threshold: …"}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "expensive",
"budget": {"cost_now": "256 unconditional march steps, each calling map() once; 14 transcendental calls; 0 texture fetches",
"cost_after": "at most 96 steps with an early break; 11 transcendental calls; 0 texture fetches",
"reduction_pct": 62,
"basis": "loop bound 256 to 96 with a hit break at the typical 40 steps, pow x3 to x1, length x5 to x4; fetches unchanged at 0",
"visual_change": "subtle",
"wins": [{"action": "Break out of the march when the ray has converged", "where": "line 37, the for loop",
"saves": "roughly 200 of the 256 map() calls on a hit pixel", "effort": "low", "visual": "none",
"before": "for (int i = 0; i < 256; i++) {\n vec3 p = ro + rd * t;\n float d = map(p);\n t += d;\n}",
"after": "for (int i = 0; i < 96; i++) {\n vec3 p = ro + rd * t;\n float d = map(p);\n if (d < 0.001 || t > 20.0) break;\n t += d;\n}"}],
"note": "calcNormal's six map() calls were left alone: they run once per hit pixel, not once per step."} }
task: "port" — move it to a three.js ShaderMaterial
Request body:
{"task":"port","shader":"float sdSphere(vec3 p, float r) {\n return length(p) - r;\n}\n…\nvoid mainImage( out vec4 fragColor, in vec2 fragCoord ) {\n…\n}","vertex":"","target":"threejs","symptom":"It runs perfectly on Shadertoy. I pasted it into my own page and got a black canvas.","carryover":"Previous lane: diagnose. Verdict: fixable.","facts":"BROWSER-MEASURED FACTS …\n- dialect: GLSL ES 1.00 (Shadertoy); entry point mainImage()\n\nFLAGS RAISED (2):\n GX-026 [info] line - — Shadertoy uniforms are supplied by the host, not by this source: …\n GX-027 [info] line - — mainImage() needs a main() wrapper outside Shadertoy: …"}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "adapted",
"port": {"target": "three.js ShaderMaterial (WebGL2 / GLSL ES 3.00)",
"fragment_shader": "precision highp float;\nuniform float iTime;\nuniform vec2 iResolution;\nin vec2 vUv;\nout vec4 fragColor;\n…\nvoid main() { mainImage(fragColor, vUv * iResolution); }",
"vertex_shader": "out vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n}",
"host_glue": "const material = new THREE.ShaderMaterial({\n uniforms: { iTime: { value: 0 }, iResolution: { value: new THREE.Vector2(1, 1) } },\n vertexShader, fragmentShader, glslVersion: THREE.GLSL3\n});\n…",
"uniform_map": [{"from": "iTime", "to": "iTime", "type": "float", "host_value": "clock.getElapsedTime()"},
{"from": "iResolution", "to": "iResolution", "type": "vec2",
"host_value": "renderer.getSize() in device-independent pixels"}],
"caveats": ["iResolution is the canvas on Shadertoy but the mesh's own footprint here, so the aspect correction changes if the quad is not full-screen",
"iTime starts at zero on page load rather than at play, so any animation phased against it starts elsewhere"],
"note": "The rename was mechanical; the fragCoord-to-vUv substitution is the judgement call, and it relies on three.js injecting uv, position, projectionMatrix and modelViewMatrix."} }
The input contract
These are the exact fields the web app submits — taken from its run path, not from intent.
{
"task": "diagnose",
"shader": "<the GLSL source, as a STRING — required>",
"vertex": "<the paired vertex shader, or \"\">",
"target": "webgl2",
"symptom": "free text: what the user sees, wants, or cannot follow",
"carryover": "optional: the previous lane's digest",
"facts": "<the browser-measured facts block, with its GX-nnn flags>"
}
That object is the request body. Do not wrap it. A body of
{"input":{"task":"diagnose",…}} is accepted, returns 200, creates a job
and bills it — and the model never sees task or shader, because the
fields it was told to read are one level deeper than it looks. The reply comes back as a review of
nothing. There is no X-App-Slug header either; the token names the app.
| Field | Type | Required | Notes |
|---|---|---|---|
task | string | yes | One of exactly four lane ids: diagnose, explain, optimize, port. Document it first because it decides everything else. |
shader | string | yes | The shader source as one string, newlines and all. This is the work object; every lane operates on this one artifact. Max about 40 000 characters. |
vertex | string | no | The vertex shader that pairs with it, when there is one. Send "" for a fragment-only paste — the interpolant-wiring checks then come back n/a with that as the reason. |
target | string | no | shadertoy, webgl1, webgl2, threejs or "". Where it has to run. The port lane ports to this; empty means a three.js ShaderMaterial. |
symptom | string | no | Free text from the user: what they see, what they want, what confuses them. The single cheapest way to aim a lane. |
carryover | string | no | A digest of the previous lane's conclusion. The model builds on it, acknowledges it, and does not re-litigate it. |
facts | string | no | The deterministic reader's output — a multi-line block, not an array. Optional, strongly recommended — see below. |
The four lane ids
diagnose— the shader will not build, or it builds and does the wrong thing. Every compiler diagnostic becomes a fix or an explicit open item, and the lane hands back a complete patched source rather than a diff. Not a performance review.explain— the shader runs and the user does not understand it: what the maths is doing, which coordinate space each step is in, which literals are worth turning. Judges nothing; it narrates.optimize— the shader works, and it should cost less per pixel without looking different. Every win carries realbeforeandafterGLSL from this shader, and a win that changes the image says so.port— move it to a three.jsShaderMaterial, or to whatevertargetnames, and hand back the fragment shader, the vertex shader, the host glue and the uniform map together.
An unrecognised task is answered by the closest lane rather than rejected: a
symptom about errors gets diagnose, one about frame rate gets
optimize, one that names another engine gets port, anything else gets
explain. The lane it chose is named in lane and explained in
overview. It will not blend two lanes' contracts — you always get one lane block and
only that lane's checks.
Clipping shader yourself
shader is capped at about 40 000 characters. Over that, the app clips
on whole-line boundaries, keeps the head and the tail, and writes
// ---- Shader Desk cut N lines here (lines A-B of T) ---- in-band where the cut
happened, so the model knows the source it is reading is incomplete and can say so. A caller
driving this API should do the same rather than a bare truncation. A shader's identity lives at
both ends: the #version line, the precision statement and the uniform block are at the
top, and the entry point — main() or mainImage() — is at the bottom. Cut
only the tail and you delete the entry point, which reads to any lane as a missing
main() rather than as your cut.
Line numbers are the other reason to mark the cut. Every lane quotes 1-based line numbers against
shader exactly as you sent it, so the marker is what lets a finding on line 180 be
understood as line 180 of the original rather than line 180 of your slice.
facts: give the model ground truth
facts is one multi-line string, produced by a parser rather than a model. The web app
fills it from the in-browser reader: the detected dialect and stage, the declaration inventory,
counted texture fetches, transcendental calls, branches and loop bounds, the portability delta in
both directions, the driver's own compile log verbatim where WebGL is available, and then a
FLAGS RAISED list where every flag carries a GX-nnn id.
The model is instructed to treat every line of it as true. A compiler diagnostic in
facts outranks the model's reading of the source: if the driver says line 14 has an
undeclared identifier, line 14 has an undeclared identifier. Counted numbers are quoted, never
paraphrased — six texture fetches is written as six, not as "a handful".
Every GX-nnn flag you send comes back exactly once in
coverage_check, in the order you sent it — with handled: true
when the output addresses it, or handled: false and an honest reason when it does not.
That is the contract, and it is the cheapest way to tell a real review from a plausible one. The
model may not invent a GX id that was not in facts.
If your parser cannot read the source, say so in facts in those words — the lane then
states plainly in overview that there were no measured facts and judges the source on
its own, which is a better answer than one anchored to nothing.
The output contract
The model replies with one JSON object as the job's output.output string. The outer
shape is identical in every lane, so one parser handles all four:
{
"lane": "diagnose",
"shader_name": "Sphere-traced SDF ball and slab",
"verdict": "<one of the lane's verdict tokens>",
"headline": "one line, under 120 characters, the single most important thing",
"overview": "2 to 5 sentences: what this shader is, and what this lane concluded",
"checks": [{"name": "<exactly the lane's check name, in the lane's order>",
"status": "pass | warn | fail | n/a",
"note": "one sentence"}],
"findings": [{"id": "SD-001",
"severity": "critical | high | medium | low",
"title": "short, specific",
"where": "line 24, or map(), or the march loop",
"why": "why it matters",
"fix": "what to do",
"snippet": "GLSL you could paste, or \"\""}],
"coverage_check": [{"flag": "GX-010", "handled": true, "note": "how this lane addressed it"}],
"next_steps": ["one short imperative sentence, in the order they should be done"],
"<lane block>": "<exactly one, see the table below>"
}
findings ids run SD-001, SD-002, … in the order listed, most
severe first. Severity means something specific here: critical is it does not compile
or does not run; high is it runs but is wrong or unsafe on some hardware;
medium is it works and costs more than it should, or will break on a move;
low is style, clarity, small waste. Zero findings is a legitimate answer and arrives
as [], never as a finding that says there are none.
checks carries every check name the lane lists, in the lane's order, even when
the answer is n/a — a short table is a bug, not brevity. A fail on any
check has a matching entry in findings, and a finding of severity
critical or high has a matching fail or warn
check: the two lists are two views of one judgement and they may not disagree. That invariant is
worth asserting in your client, because a reply where they diverge is a reply to distrust.
Snippet fields are GLSL, not prose. snippet, change, before,
after, patched_source, fragment_shader and
vertex_shader all contain code you could paste, with no fences and no line-number
prefixes inside the string.
The lane block: exactly one, chosen by task
task | verdict tokens | Key | Shape |
|---|---|---|---|
diagnose |
compiles / fixable / broken |
repair |
Object: {ready (bool), fixes[{line, problem, change, confidence}], patched_source (string), still_open[], note}. confidence is certain, likely or guess; only certain fixes are applied to patched_source, and ready is true only when that source compiles as written. |
explain |
clear / dense / obscure |
walkthrough |
Object: {technique, one_liner, sections[{lines, title, what, math}], uniforms[{name, type, means, typical}], knobs[{name, line, effect, try}], note}. sections cover the source top to bottom without overlapping, every lines range is inside the source, and knobs holds three to eight numbers actually worth turning. |
optimize |
lean / tunable / expensive |
budget |
Object: {cost_now, cost_after, reduction_pct (integer 0-95), basis, visual_change, wins[{action, where, saves, effort, visual, before, after}], note}. cost_now agrees with the counted numbers in facts exactly; basis shows the arithmetic so you can disagree with it; visual_change and each win's visual are none, subtle or visible. |
port |
direct / adapted / blocked |
port |
Object: {target, fragment_shader, vertex_shader, host_glue, uniform_map[{from, to, type, host_value}], caveats[], note}. The two shaders are complete and self-consistent — the interpolants the fragment stage reads are the ones the vertex stage writes — and host_glue is real JavaScript naming the same uniforms as uniform_map. |
Route on lane, then read that one key. The other three are absent, not null — a client
that reaches for budget on a diagnose reply is reading a lane it did not
ask for. Note the one collision worth knowing about: the port lane's block is also
called port, and inside it port.target echoes where the shader was ported
to, which is not necessarily the string you sent as target.
The checks each lane returns
Ten per lane, always in this order. Knowing the list up front means you can build the table before the reply lands and fill it in as the stream arrives.
| Lane | checks[].name, in order |
|---|---|
diagnose | compile errors, version and dialect, precision qualifiers, declaration completeness, type correctness, uniform and interpolant wiring, entry point shape, loop and branch legality, sampler usage, undefined behaviour |
explain | entry point, coordinate space, uniform contract, core technique, helper functions, colour pipeline, animation, control flow, constants and magic numbers, output range |
optimize | texture fetches, transcendental calls, loop bounds, branch divergence, precision choice, redundant computation, normalize and length, step budget, dependent texture reads, early exit |
port | dialect translation, entry point rewrite, uniform mapping, interpolant wiring, texture sampling calls, output variable, precision declarations, built-in substitutions, host glue completeness, behavioural differences |
The vocabulary is fixed too. pass — the shader is fine on this axis.
warn — it works but there is something the author should know. fail — it
is wrong on this axis and a finding says so. n/a — the axis does not apply to this
shader, with the note saying why. A fragment-only paste, for instance, gets n/a on
interpolant wiring rather than a guess.
Step by step
1. Get a token
Every call needs Authorization: Bearer <token>. Two ways to get one:
- Your own account token — open the token panel, sign
in, and copy it. No DevTools, no digging through storage: the panel mints a token scoped to
shader-deskand shows it once. This is the one that spends your credits and sees your run history. - A guest token —
POST /v1/app-api/guestwith{"slug":"shader-desk"}and noAuthorizationheader at all mints an anonymous subject. Guests can call/meand/estimate; a/runis metered and this app does not sponsor guests, so a guest run returns403 FORBIDDEN.
Keep the token out of your source. Read it from the environment at runtime and never commit it.
# An account token: copy it from https://shader-desk.skillsafe.ai/tokens.html
TOKEN="YOUR_TOKEN"
# Or mint a guest token — no Authorization header on this one call.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"shader-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","subject_type":"guest"}}
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
# Preferred: an account token from the token panel, read from the environment.
TOKEN = os.environ.get("SHADER_DESK_TOKEN") or "YOUR_TOKEN"
def mint_guest():
"""A guest can /me and /estimate, but not /run."""
req = urllib.request.Request(
BASE + "/guest", data=json.dumps({"slug": "shader-desk"}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]["token"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
// Paste an account token from /tokens.html, or inject it at runtime.
const TOKEN = "YOUR_TOKEN";
// A guest token: no Authorization header on this one call.
async function mintGuest() {
const res = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "shader-desk" })
});
const json = await res.json();
return json.data.token; // guests can /me and /estimate, not /run
}
package main
import (
"bytes"
"encoding/json"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func token() string {
if t := os.Getenv("SHADER_DESK_TOKEN"); t != "" {
return t
}
return "YOUR_TOKEN"
}
func mintGuest() (string, error) {
body, _ := json.Marshal(map[string]string{"slug": "shader-desk"})
res, err := http.Post(base+"/guest", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
Data struct{ Token string } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
return out.Data.Token, nil
}
import java.net.URI;
import java.net.http.*;
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
// An account token from /tokens.html, via the environment.
static final String TOKEN =
System.getenv("SHADER_DESK_TOKEN") != null ? System.getenv("SHADER_DESK_TOKEN") : "YOUR_TOKEN";
static String mintGuest() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"shader-desk\"}"))
.build();
// -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
return HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV["SHADER_DESK_TOKEN"] || "YOUR_TOKEN"
def mint_guest
uri = URI(BASE + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => "shader-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]["token"] # guests cannot /run
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("SHADER_DESK_TOKEN") ?: "YOUR_TOKEN";
function mint_guest(): string {
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "shader-desk"]),
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
return $body["data"]["token"]; // guests cannot /run
}
using System.Net.Http.Json;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
var token = Environment.GetEnvironmentVariable("SHADER_DESK_TOKEN") ?? "YOUR_TOKEN";
static async Task<string> MintGuest() {
using var anon = new HttpClient();
var res = await anon.PostAsJsonAsync(Base + "/guest", new { slug = "shader-desk" });
var doc = await res.Content.ReadFromJsonAsync<JsonElement>();
return doc.GetProperty("data").GetProperty("token").GetString()!; // no /run for guests
}
2. A tiny client helper
Two things repeat on every call: the Authorization header, and unwrapping
data out of the envelope. Write them once. Everything after this step uses the
call helper below, and every one of these raises on ok: false instead of
returning a half-empty object.
# The shell equivalent of a helper: a function plus jq for the unwrap.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"
sd() { # sd GET /me | sd POST /estimate "$body"
curl -s -X "$1" "$BASE$2" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
${3:+-d "$3"} \
| jq -e 'if .ok then .data else error("\(.error.code): \(.error.message)") end'
}
import json, urllib.error, urllib.request
class ApiError(Exception):
pass
def call(path, payload=None, method=None, headers=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=data,
method=method or ("POST" if data else "GET"))
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as r:
body = json.load(r)
except urllib.error.HTTPError as e:
body = json.load(e)
if not body.get("ok"):
err = body.get("error", {})
raise ApiError(f"{err.get('code')}: {err.get('message')}")
return body["data"]
async function call(path, payload, { method, headers } = {}) {
const res = await fetch(BASE + path, {
method: method || (payload ? "POST" : "GET"),
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...(headers || {})
},
body: payload ? JSON.stringify(payload) : undefined
});
const json = await res.json();
if (!json.ok) throw new Error(`${json.error?.code}: ${json.error?.message}`);
return json.data;
}
type apiError struct{ Code, Message string }
func (e apiError) Error() string { return e.Code + ": " + e.Message }
func call(method, path string, payload any, headers map[string]string) (map[string]any, error) {
var body *bytes.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
} else {
body = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
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 out struct {
Ok bool `json:"ok"`
Data map[string]any `json:"data"`
Error apiError `json:"error"`
}
json.NewDecoder(res.Body).Decode(&out)
if !out.Ok {
return nil, out.Error
}
return out.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
static final HttpClient HTTP = HttpClient.newHttpClient();
/** Returns the raw body; parse it with your JSON library of choice. */
static String call(String method, String path, String json, Map<String, String> headers)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
if (headers != null) headers.forEach(b::header);
b = (json == null) ? b.GET() : b.method(method, HttpRequest.BodyPublishers.ofString(json));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.body().contains("\"ok\":false")) throw new RuntimeException(res.body());
return res.body(); // {"ok":true,"data":{...}}
}
class ApiError < StandardError; end
def call(path, payload = nil, method: nil, headers: {})
uri = URI(BASE + path)
verb = method || (payload ? "POST" : "GET")
req = (verb == "POST" ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
headers.each { |k, v| req[k] = v }
req.body = JSON.dump(payload) if payload
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
body = JSON.parse(res.body)
raise ApiError, "#{body.dig('error', 'code')}: #{body.dig('error', 'message')}" unless body["ok"]
body["data"]
end
<?php
class ApiError extends RuntimeException {}
function call(string $path, ?array $payload = null, array $headers = []) {
global $token;
$ch = curl_init(BASE . $path);
$hdr = array_merge(
["Authorization: Bearer $token", "Content-Type: application/json"], $headers);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $hdr,
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($body["ok"])) {
throw new ApiError(($body["error"]["code"] ?? "ERROR") . ": "
. ($body["error"]["message"] ?? ""));
}
return $body["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
async Task<JsonElement> Call(string path, object? payload = null,
(string Name, string Value)? header = null) {
var msg = new HttpRequestMessage(
payload is null ? HttpMethod.Get : HttpMethod.Post, Base + path);
if (payload is not null) msg.Content = JsonContent.Create(payload);
if (header is not null) msg.Headers.Add(header.Value.Name, header.Value.Value);
var res = await http.SendAsync(msg);
var doc = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!doc.GetProperty("ok").GetBoolean()) {
var e = doc.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return doc.GetProperty("data");
}
3. Check the session and the balance
GET /me tells you which subject the token belongs to and how many credits it holds.
Read two fields: subject_type (user or guest) and
credits. Do this before a run — a guest here means the run will come back
403 no matter how healthy the balance looks, and comparing credits
against the estimate's min_credits is how you avoid a 402 after
submitting.
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","credits":41230,"app":"shader-desk"}}
me = call("/me")
print(me["subject_type"], me["credits"])
if me["subject_type"] != "user":
raise SystemExit("a guest cannot run a lane in this app")
const me = await call("/me");
console.log(me.subject_type, me.credits);
if (me.subject_type !== "user") throw new Error("a guest cannot run a lane in this app");
me, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
fmt.Println(me["subject_type"], me["credits"])
String me = call("GET", "/me", null, null);
System.out.println(me); // {"ok":true,"data":{"subject_type":"user","credits":41230}}
me = call("/me")
puts "#{me['subject_type']} #{me['credits']}"
abort "a guest cannot run a lane in this app" unless me["subject_type"] == "user"
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
if ($me["subject_type"] !== "user") {
exit("a guest cannot run a lane in this app\n");
}
var me = await Call("/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
4. Estimate the lane — free, no job
POST /estimate takes the same body as /run — the input object, unwrapped
— costs nothing and creates no job. Assert three things on the way back, because they are the
contract this page is written against:
modelisgpt-5.6-terramodel_aliasisgpt-terramarkup_bpsis1000
It also returns hold_credits, min_credits and
sponsor_enabled (false here — that is why guests cannot run).
hold_credits is a reservation, not a price. It is the ceiling the
platform sets aside while the job runs, sized for the worst case of that lane's output cap. The
charged_credits you see on the settled job is usually far lower — a reply that comes
in short is billed short. Budget against hold_credits so a run is never rejected
mid-flight; report against charged_credits.
Re-estimate on every lane change. The hold differs per lane because the prompt
sections and output caps differ, and here the spread is wide: port returns two
complete shaders plus the host glue, and diagnose returns the whole patched source,
so both carry far higher ceilings than explain or optimize, which return
prose and fragments. An estimate for explain does not price port. The web
app re-estimates on every lane switch for exactly this reason.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task":"diagnose","shader":"#version 300 es\nvarying vec2 vUv;\nvoid main() {}","vertex":"","target":"webgl2","symptom":"four errors I do not understand","carryover":"","facts":""}'
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":9600,"min_credits":9600,"sponsor_enabled":false}}
inp = {"task": "diagnose", "shader": shader_text, "vertex": "",
"target": "webgl2", "symptom": "four errors I do not understand",
"carryover": "", "facts": facts_text}
est = call("/estimate", inp) # the input object IS the body
assert est["model"] == "gpt-5.6-terra", est["model"]
assert est["model_alias"] == "gpt-terra", est["model_alias"]
assert est["markup_bps"] == 1000, est["markup_bps"]
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
const input = {
task: "diagnose", shader: shaderText, vertex: "", target: "webgl2",
symptom: "four errors I do not understand", carryover: "", facts: factsText
};
const est = await call("/estimate", input); // no { input: ... } wrapper
if (est.model !== "gpt-5.6-terra") throw new Error(`unexpected model ${est.model}`);
if (est.model_alias !== "gpt-terra") throw new Error(`unexpected alias ${est.model_alias}`);
if (est.markup_bps !== 1000) throw new Error(`unexpected markup ${est.markup_bps}`);
console.log(est.hold_credits, est.min_credits, est.sponsor_enabled);
input := map[string]any{
"task": "diagnose", "shader": shaderText, "vertex": "",
"target": "webgl2", "symptom": "four errors I do not understand",
"carryover": "", "facts": factsText,
}
est, err := call("POST", "/estimate", input, nil)
if err != nil {
panic(err)
}
if est["model"] != "gpt-5.6-terra" || est["model_alias"] != "gpt-terra" {
panic(fmt.Sprintf("unexpected model %v", est["model"]))
}
fmt.Println(est["markup_bps"], est["hold_credits"], est["min_credits"])
// jsonQuoted() is your JSON string escaper; the shader is a STRING with real newlines.
String body = "{\"task\":\"diagnose\",\"shader\":" + jsonQuoted(shaderText)
+ ",\"vertex\":\"\",\"target\":\"webgl2\""
+ ",\"symptom\":\"four errors I do not understand\""
+ ",\"carryover\":\"\",\"facts\":" + jsonQuoted(factsText) + "}";
String est = call("POST", "/estimate", body, null);
if (!est.contains("\"model\":\"gpt-5.6-terra\"")) throw new RuntimeException(est);
if (!est.contains("\"markup_bps\":1000")) throw new RuntimeException(est);
System.out.println(est);
input = { "task" => "diagnose", "shader" => shader_text, "vertex" => "",
"target" => "webgl2", "symptom" => "four errors I do not understand",
"carryover" => "", "facts" => facts_text }
est = call("/estimate", input)
raise "unexpected model #{est['model']}" unless est["model"] == "gpt-5.6-terra"
raise "unexpected alias #{est['model_alias']}" unless est["model_alias"] == "gpt-terra"
raise "unexpected markup #{est['markup_bps']}" unless est["markup_bps"] == 1000
puts est["hold_credits"], est["min_credits"]
$input = ["task" => "diagnose", "shader" => $shaderText, "vertex" => "",
"target" => "webgl2", "symptom" => "four errors I do not understand",
"carryover" => "", "facts" => $factsText];
$est = call("/estimate", $input);
if ($est["model"] !== "gpt-5.6-terra" || $est["model_alias"] !== "gpt-terra") {
throw new RuntimeException("unexpected model " . $est["model"]);
}
if ($est["markup_bps"] !== 1000) {
throw new RuntimeException("unexpected markup " . $est["markup_bps"]);
}
echo $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
var input = new {
task = "diagnose", shader = shaderText, vertex = "", target = "webgl2",
symptom = "four errors I do not understand", carryover = "", facts = factsText
};
var est = await Call("/estimate", input);
if (est.GetProperty("model").GetString() != "gpt-5.6-terra") throw new Exception("model");
if (est.GetProperty("model_alias").GetString() != "gpt-terra") throw new Exception("alias");
if (est.GetProperty("markup_bps").GetInt32() != 1000) throw new Exception("markup");
Console.WriteLine(est.GetProperty("hold_credits"));
5. Run it, then poll the job
POST /run takes the input object as the body and returns
{"job_id": "job_…", "status": "queued"} immediately. Poll
GET /jobs/{job_id} until status is terminal —
succeeded, failed or cancelled — no faster than once a
second, and back off on a 429. The reply is the string at
job.output.output; parse it as JSON.
Always send an Idempotency-Key header, and derive it from a hash of
(task, shader, attempt). The lane belongs in the key because two lanes over
the same shader are two distinct runs and must not collide; the shader belongs in it because the
same lane over an edited source is a new run — and editing the source is exactly what this app
encourages between lanes; the attempt counter belongs in it because a deliberate re-run of an
identical input is a second answer you are choosing to pay for.
A retry must reuse the same key. A network timeout, a dropped connection, a
500 — none of those tell you whether the job was created. Replaying the request with
the same key returns the original job instead of starting a second one. Minting a fresh key on
retry is how you get billed twice for one run, and nothing downstream will tell you it happened:
you will simply have two jobs and one answer you wanted.
BODY='{"task":"diagnose","shader":"#version 300 es\nvarying vec2 vUv;\nvoid main() {}","vertex":"","target":"webgl2","symptom":"four errors I do not understand","carryover":"","facts":""}'
# The key: lane, a hash of the shader source, and the attempt number.
DIGEST=$(printf '%s' "diagnose$BODY" | shasum -a 256 | cut -c1-12)
KEY="shader-desk:diagnose:$DIGEST:a1"
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY"
# -> {"ok":true,"data":{"job_id":"job_123","status":"queued"}}
# Poll. Same key on any retry of the POST above, or you pay twice.
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/jobs/job_123" \
-H "Authorization: Bearer $TOKEN"
import hashlib, json, time
def idem_key(inp, attempt=1):
"""(task, shader, attempt) -> one stable key. Reuse it on every retry."""
digest = hashlib.sha256(
(inp["task"] + "\x00" + inp["shader"]).encode()).hexdigest()[:12]
return f"shader-desk:{inp['task']}:{digest}:a{attempt}"
key = idem_key(inp)
job = call("/run", inp, headers={"Idempotency-Key": key})
while job["status"] not in ("succeeded", "failed", "cancelled"):
time.sleep(1.5)
job = call("/jobs/" + job["job_id"])
if job["status"] != "succeeded":
raise SystemExit(job.get("error") or job["status"])
result = json.loads(job["output"]["output"])
print(result["lane"], result["verdict"], job.get("charged_credits"))
import { createHash } from "node:crypto";
// (task, shader, attempt) -> one stable key. Reuse it on every retry.
function idemKey(input, attempt = 1) {
const digest = createHash("sha256")
.update(`${input.task}\u0000${input.shader}`)
.digest("hex")
.slice(0, 12);
return `shader-desk:${input.task}:${digest}:a${attempt}`;
}
const key = idemKey(input);
let job = await call("/run", input, { headers: { "Idempotency-Key": key } });
while (!["succeeded", "failed", "cancelled"].includes(job.status)) {
await new Promise(r => setTimeout(r, 1500));
job = await call(`/jobs/${job.job_id}`);
}
if (job.status !== "succeeded") throw new Error(job.error || job.status);
const result = JSON.parse(job.output.output);
console.log(result.lane, result.verdict, job.charged_credits);
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
// (task, shader, attempt) -> one stable key. Reuse it on every retry.
func idemKey(task, shader string, attempt int) string {
sum := sha256.Sum256([]byte(task + "\x00" + shader))
return fmt.Sprintf("shader-desk:%s:%s:a%d", task, hex.EncodeToString(sum[:])[:12], attempt)
}
key := idemKey("diagnose", shaderText, 1)
job, err := call("POST", "/run", input, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
for {
status, _ := job["status"].(string)
if status == "succeeded" || status == "failed" || status == "cancelled" {
break
}
time.Sleep(1500 * time.Millisecond)
job, _ = call("GET", "/jobs/"+job["job_id"].(string), nil, nil)
}
// job["output"].(map[string]any)["output"].(string) is the reply JSON
import java.security.MessageDigest;
// (task, shader, attempt) -> one stable key. Reuse it on every retry.
static String idemKey(String task, String shader, int attempt) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256")
.digest((task + "\0" + shader).getBytes("UTF-8"));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 6; i++) hex.append(String.format("%02x", d[i]));
return "shader-desk:" + task + ":" + hex + ":a" + attempt;
}
String key = idemKey("diagnose", shaderText, 1);
String job = call("POST", "/run", body, Map.of("Idempotency-Key", key));
// then poll GET /jobs/{job_id} on the same helper until status is terminal,
// resending /run with THIS key — never a new one — if the POST itself failed.
require "digest"
# (task, shader, attempt) -> one stable key. Reuse it on every retry.
def idem_key(input, attempt = 1)
digest = Digest::SHA256.hexdigest("#{input['task']}\0#{input['shader']}")[0, 12]
"shader-desk:#{input['task']}:#{digest}:a#{attempt}"
end
key = idem_key(input)
job = call("/run", input, headers: { "Idempotency-Key" => key })
until %w[succeeded failed cancelled].include?(job["status"])
sleep 1.5
job = call("/jobs/#{job['job_id']}")
end
abort(job["error"].to_s) unless job["status"] == "succeeded"
result = JSON.parse(job.dig("output", "output"))
puts result["lane"], result["verdict"]
<?php
// (task, shader, attempt) -> one stable key. Reuse it on every retry.
function idem_key(array $input, int $attempt = 1): string {
$digest = substr(hash("sha256", $input["task"] . "\0" . $input["shader"]), 0, 12);
return "shader-desk:{$input['task']}:{$digest}:a{$attempt}";
}
$key = idem_key($input);
$job = call("/run", $input, ["Idempotency-Key: $key"]);
while (!in_array($job["status"], ["succeeded", "failed", "cancelled"], true)) {
usleep(1500000);
$job = call("/jobs/" . $job["job_id"]);
}
if ($job["status"] !== "succeeded") {
throw new RuntimeException($job["error"] ?? $job["status"]);
}
$result = json_decode($job["output"]["output"], true);
echo $result["lane"], " ", $result["verdict"], PHP_EOL;
using System.Security.Cryptography;
using System.Text;
// (task, shader, attempt) -> one stable key. Reuse it on every retry.
static string IdemKey(string task, string shader, int attempt = 1) {
var d = SHA256.HashData(Encoding.UTF8.GetBytes(task + "\0" + shader));
return $"shader-desk:{task}:{Convert.ToHexString(d)[..12].ToLowerInvariant()}:a{attempt}";
}
var key = IdemKey("diagnose", shaderText);
var job = await Call("/run", input, ("Idempotency-Key", key));
var jobId = job.GetProperty("job_id").GetString();
string status;
do {
await Task.Delay(1500);
job = await Call($"/jobs/{jobId}");
status = job.GetProperty("status").GetString()!;
} while (status is not ("succeeded" or "failed" or "cancelled"));
var result = JsonDocument.Parse(
job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
6. Or stream it
POST /run-stream is the same call, the same body and the same
Idempotency-Key, delivered as server-sent events. Each line of interest starts with
data: and carries one JSON event with a type:
job— arrives first, as soon as the job exists, carryingjob_id. Keep it: if the stream dies you can fall back to pollingGET /jobs/{job_id}for the same run rather than paying for a second one.delta— a text fragment of the reply. Appendevent.textto a buffer. Fragments are not JSON on their own and are not line-aligned; do not try to parse one.job, terminal — the settled job at the end of the stream, withstatus,output,charged_creditsandtruncated. This is the authoritative record; the concatenated deltas are only a preview of it.
The web app uses this route so its progress card can advance as sections arrive. Streaming is worth
more here than on most apps: the diagnose and port lanes end with a whole
shader in a string field, so the last third of the reply is often the longest part of the wait, and
the verdict, the checks and the fix list are all readable before it lands. If the stream dies
mid-flight, keep what arrived — see step 7 for closing a truncated buffer
rather than throwing the run away.
curl -s -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY"
# data: {"type":"job","job_id":"job_123","status":"running"}
# data: {"type":"delta","text":"{\"lane\":\"diagnose\","}
# data: {"type":"delta","text":"\"shader_name\":\"Half-ported post"}
# ...
# data: {"type":"job","job_id":"job_123","status":"succeeded","charged_credits":2480,
# "truncated":false,"output":{"output":"{...the whole reply...}"}}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(inp).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key) # the same key as /run
buf, job = "", None
with urllib.request.urlopen(req) as stream:
for raw in stream:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
evt = json.loads(line[5:])
if evt.get("type") == "delta":
buf += evt["text"] # a preview, not parseable yet
elif evt.get("type") == "job":
job = evt # first one has the id, last one settles
# Prefer the terminal job; fall back to the buffer if the stream died.
raw_result = job["output"]["output"] if job and job.get("output") else buf
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json",
"Idempotency-Key": key }, // the same key as /run
body: JSON.stringify(input)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let pending = "", buf = "", job = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
const lines = pending.split("\n");
pending = lines.pop(); // keep the partial line
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const evt = JSON.parse(line.slice(5));
if (evt.type === "delta") buf += evt.text;
else if (evt.type === "job") job = evt; // id first, settled job last
}
}
const rawResult = job?.output?.output ?? buf;
b, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key) // the same key as /run
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var buf strings.Builder
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var evt struct {
Type string `json:"type"`
Text string `json:"text"`
JobID string `json:"job_id"`
}
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &evt)
switch evt.Type {
case "delta":
buf.WriteString(evt.Text)
case "job":
jobID = evt.JobID // keep it: a dead stream can be recovered by polling
}
}
HttpResponse<Stream<String>> res = HTTP.send(
HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key) // the same key as /run
.POST(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofLines());
StringBuilder buf = new StringBuilder();
res.body()
.filter(l -> l.startsWith("data:"))
.map(l -> l.substring(5))
.forEach(payload -> {
// parse payload with your JSON library:
// type "delta" -> buf.append(text)
// type "job" -> remember job_id, and the terminal job settles the run
if (payload.contains("\"type\":\"delta\"")) buf.append(textOf(payload));
});
uri = URI(BASE + "/run-stream")
buf = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key # the same key as /run
req.body = JSON.dump(input)
pending = +""
http.request(req) do |res|
res.read_body do |chunk|
pending << chunk
while (nl = pending.index("\n"))
line = pending.slice!(0, nl + 1).strip
next unless line.start_with?("data:")
evt = JSON.parse(line[5..])
buf << evt["text"] if evt["type"] == "delta"
@job = evt if evt["type"] == "job"
end
end
end
end
<?php
$buf = "";
$pending = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: $key", // the same key as /run
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$pending) {
$pending .= $chunk;
while (($nl = strpos($pending, "\n")) !== false) {
$line = trim(substr($pending, 0, $nl));
$pending = substr($pending, $nl + 1);
if (strncmp($line, "data:", 5) !== 0) continue;
$evt = json_decode(substr($line, 5), true);
if (($evt["type"] ?? "") === "delta") $buf .= $evt["text"];
// ($evt["type"] ?? "") === "job" -> the id first, the settled job last
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var msg = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = JsonContent.Create(input)
};
msg.Headers.Add("Idempotency-Key", key); // the same key as /run
using var res = await http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
JsonElement? settled = null;
while (await reader.ReadLineAsync() is string line) {
if (!line.StartsWith("data:")) continue;
var evt = JsonDocument.Parse(line[5..]).RootElement;
var type = evt.GetProperty("type").GetString();
if (type == "delta") buf.Append(evt.GetProperty("text").GetString());
else if (type == "job") settled = evt; // id first, settled job last
}
7. Parse the result
Four moves, in this order, whichever route you took:
- Get the JSON object out of the reply. The model is instructed to emit one object
and nothing else — first character
{, last character}— so a strict parse ofoutput.outputnormally works. Be tolerant anyway: slice from the first{to the last}before parsing, so a stray fence or a leading newline is not an outage. - Route on
lane, not on what you asked for. An unrecognisedtaskis answered by the nearest lane, and that lane is whatlanesays. - Read that lane's block and nothing else.
repair,walkthrough,budget,port— exactly one is present. - Handle a truncated reply. When the job carries
"truncated": true, or when a stream died, close the buffer at the last complete structure and render what parsed.headline,overviewand the first checks arrive early and are worth showing; throwing the whole run away becausepatched_sourcenever landed wastes a credit you have already spent — and the fix list above it is usually enough to patch the shader by hand.
One more assertion worth writing once: every GX-nnn id you put in facts
should appear exactly once in coverage_check. A missing id, a duplicate, or an id you
never sent are all signals to distrust the reply rather than render it.
JOB=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/job_123" \
-H "Authorization: Bearer $TOKEN")
# The reply is a JSON string inside the job. Unwrap it once, then read it.
RESULT=$(echo "$JOB" | jq -r '.data.output.output')
echo "$RESULT" | jq -r '.lane, .verdict, .headline'
echo "$RESULT" | jq -r '.checks[] | "\(.status)\t\(.name)"'
echo "$RESULT" | jq -r '.coverage_check[] | "\(.flag)\thandled=\(.handled)"'
# The lane block: exactly one of these is present.
echo "$RESULT" | jq '.repair // .walkthrough // .budget // .port'
# The patched shader, when the lane was diagnose:
echo "$RESULT" | jq -r '.repair.ready, (.repair.patched_source // empty)'
# Was it cut short?
echo "$JOB" | jq '.data.truncated, .data.charged_credits'
def parse_result(raw):
"""Tolerant parse: slice to the outermost braces before json.loads."""
start, end = raw.find("{"), raw.rfind("}")
if start < 0:
raise ValueError("no JSON object in the reply")
return json.loads(raw[start:end + 1])
result = parse_result(job["output"]["output"])
LANE_BLOCK = {"diagnose": "repair", "explain": "walkthrough",
"optimize": "budget", "port": "port"}
lane = result["lane"] # route on this, not on inp["task"]
block = result.get(LANE_BLOCK[lane])
print(result["verdict"], "-", result["headline"])
for c in result.get("checks", []):
print(f" [{c['status']:4}] {c['name']}: {c['note']}")
for f in result.get("findings", []):
print(f" {f['id']} {f['severity']}: {f['title']} @ {f['where']}")
for cc in result.get("coverage_check", []):
print(f" {cc['flag']} handled={cc['handled']}: {cc['note']}")
if lane == "diagnose" and block["ready"]:
print(block["patched_source"]) # compiles as written; hand it to your build
elif lane == "optimize":
print(block["cost_now"], "->", block["cost_after"], f"({block['reduction_pct']}%)")
elif lane == "port":
print(block["fragment_shader"], block["host_glue"], sep="\n---\n")
if job.get("truncated"):
# Render what parsed; do not discard the run.
print("reply was cut short - sections above are complete, the rest is missing")
const LANE_BLOCK = { diagnose: "repair", explain: "walkthrough",
optimize: "budget", port: "port" };
function parseResult(raw) {
const start = raw.indexOf("{"), end = raw.lastIndexOf("}");
if (start < 0) throw new Error("no JSON object in the reply");
try {
return JSON.parse(raw.slice(start, end + 1));
} catch (_) {
// A dead stream: close the buffer at the last complete top-level entry.
const cut = raw.lastIndexOf("],");
if (cut < 0) throw new Error("nothing parseable arrived");
return JSON.parse(raw.slice(start, cut + 1) + "}");
}
}
const result = parseResult(rawResult);
const block = result[LANE_BLOCK[result.lane]]; // route on result.lane
console.log(result.verdict, "-", result.headline);
result.checks?.forEach(c => console.log(` [${c.status}] ${c.name}: ${c.note}`));
result.findings?.forEach(f => console.log(` ${f.id} ${f.severity}: ${f.title} @ ${f.where}`));
result.coverage_check?.forEach(cc => console.log(` ${cc.flag} handled=${cc.handled}`));
if (result.lane === "diagnose" && block.ready) {
material.fragmentShader = block.patched_source; // it compiles as written
}
if (result.lane === "port") {
console.log(block.host_glue); // real JS, not pseudocode
}
if (job?.truncated) console.warn("reply cut short - rendering the sections that parsed");
var laneBlock = map[string]string{
"diagnose": "repair", "explain": "walkthrough",
"optimize": "budget", "port": "port",
}
raw := job["output"].(map[string]any)["output"].(string)
if i, j := strings.Index(raw, "{"), strings.LastIndex(raw, "}"); i >= 0 && j > i {
raw = raw[i : j+1]
}
var result map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
// Truncated: render whatever the caller already streamed rather than dropping it.
log.Printf("reply not parseable in full: %v", err)
}
lane, _ := result["lane"].(string)
block := result[laneBlock[lane]]
fmt.Println(result["verdict"], result["headline"], lane)
for _, c := range result["checks"].([]any) {
m := c.(map[string]any)
fmt.Printf(" [%v] %v: %v\n", m["status"], m["name"], m["note"])
}
if lane == "diagnose" {
r := block.(map[string]any)
if ready, _ := r["ready"].(bool); ready {
fmt.Println(r["patched_source"]) // compiles as written
}
}
if t, _ := job["truncated"].(bool); t {
fmt.Println("reply was cut short")
}
// laneBlock: diagnose -> repair, explain -> walkthrough,
// optimize -> budget, port -> port
static final Map<String, String> LANE_BLOCK = Map.of(
"diagnose", "repair", "explain", "walkthrough",
"optimize", "budget", "port", "port");
static String sliceObject(String raw) {
int start = raw.indexOf('{'), end = raw.lastIndexOf('}');
if (start < 0) throw new IllegalArgumentException("no JSON object in the reply");
return raw.substring(start, Math.max(end + 1, start + 1));
}
// With your JSON library:
// var result = mapper.readTree(sliceObject(outputOutput));
// String lane = result.get("lane").asText(); // route on this
// var block = result.get(LANE_BLOCK.get(lane)); // exactly one is present
// for (var c : result.withArray("checks")) { ... }
// for (var cc : result.withArray("coverage_check")) { ... }
// if (lane.equals("diagnose") && block.get("ready").asBoolean())
// String patched = block.get("patched_source").asText(); // compiles as written
// If the parse throws, render the fields you already have rather than
// discarding the run: job.truncated == true is the expected cause.
LANE_BLOCK = { "diagnose" => "repair", "explain" => "walkthrough",
"optimize" => "budget", "port" => "port" }.freeze
def parse_result(raw)
start = raw.index("{")
raise "no JSON object in the reply" unless start
JSON.parse(raw[start..raw.rindex("}")])
end
result = parse_result(job.dig("output", "output"))
block = result[LANE_BLOCK[result["lane"]]] # route on result["lane"]
puts "#{result['verdict']} - #{result['headline']}"
result.fetch("checks", []).each { |c| puts " [#{c['status']}] #{c['name']}: #{c['note']}" }
result.fetch("findings", []).each { |f| puts " #{f['id']} #{f['severity']}: #{f['title']}" }
result.fetch("coverage_check", []).each { |cc| puts " #{cc['flag']} #{cc['handled']}" }
case result["lane"]
when "diagnose" then puts block["patched_source"] if block["ready"]
when "optimize" then puts "#{block['cost_now']} -> #{block['cost_after']}"
when "port" then puts block["fragment_shader"], block["host_glue"]
end
warn "reply cut short - rendering what parsed" if job["truncated"]
<?php
const LANE_BLOCK = ["diagnose" => "repair", "explain" => "walkthrough",
"optimize" => "budget", "port" => "port"];
function parse_result(string $raw): array {
$start = strpos($raw, "{");
if ($start === false) throw new RuntimeException("no JSON object in the reply");
$end = strrpos($raw, "}");
$obj = json_decode(substr($raw, $start, $end - $start + 1), true);
if (!is_array($obj)) throw new RuntimeException("reply did not parse");
return $obj;
}
$result = parse_result($job["output"]["output"]);
$block = $result[LANE_BLOCK[$result["lane"]]] ?? null; // route on lane
printf("%s - %s\n", $result["verdict"], $result["headline"]);
foreach ($result["checks"] ?? [] as $c) {
printf(" [%s] %s: %s\n", $c["status"], $c["name"], $c["note"]);
}
foreach ($result["coverage_check"] ?? [] as $cc) {
printf(" %s handled=%s\n", $cc["flag"], $cc["handled"] ? "true" : "false");
}
if ($result["lane"] === "diagnose" && !empty($block["ready"])) {
echo $block["patched_source"], PHP_EOL; // compiles as written
}
if (!empty($job["truncated"])) {
error_log("reply cut short - rendering the sections that parsed");
}
var laneBlock = new Dictionary<string, string> {
["diagnose"] = "repair", ["explain"] = "walkthrough",
["optimize"] = "budget", ["port"] = "port"
};
static JsonElement ParseResult(string raw) {
var start = raw.IndexOf('{');
var end = raw.LastIndexOf('}');
if (start < 0) throw new Exception("no JSON object in the reply");
return JsonDocument.Parse(raw[start..(end + 1)]).RootElement;
}
var result = ParseResult(job.GetProperty("output").GetProperty("output").GetString()!);
var lane = result.GetProperty("lane").GetString()!; // route on this
var block = result.GetProperty(laneBlock[lane]);
Console.WriteLine($"{result.GetProperty("verdict")} - {result.GetProperty("headline")}");
foreach (var c in result.GetProperty("checks").EnumerateArray())
Console.WriteLine($" [{c.GetProperty("status")}] {c.GetProperty("name")}");
foreach (var cc in result.GetProperty("coverage_check").EnumerateArray())
Console.WriteLine($" {cc.GetProperty("flag")} {cc.GetProperty("handled")}");
if (lane == "diagnose" && block.GetProperty("ready").GetBoolean())
Console.WriteLine(block.GetProperty("patched_source").GetString()!);
if (job.TryGetProperty("truncated", out var t) && t.GetBoolean())
Console.WriteLine("reply cut short - rendering the sections that parsed");
What the free reader gives you
Before any lane is run, the web app reads the shader in the browser. That reader is the whole of
what this app does for free: no account, no token, no job, nothing uploaded. It detects the dialect
and the stage, inventories every uniform, attribute, varying, in, out,
const, struct and function with its line and whether it is ever read, counts texture fetches,
transcendental calls, branches and loop bounds, works out what moving to GLSL ES 3.00 or back to
1.00 would cost in changes — and then, where WebGL is available, hands the source to the
machine's own GLSL compiler and reports the driver's log verbatim, line numbers corrected
for the wrapper it had to add.
Three things come out of it, and all three are free:
- The
factsblock — the measurements above plus aFLAGS RAISEDlist, each flag carrying aGX-nnnid. This is what you put in thefactsfield. - A real compile — not a heuristic.
ERROR: 0:14: …from the driver that would have run the shader, which is why thediagnoselane is told a compiler diagnostic outranks its own reading of the source. - A live preview — the shader animating in a canvas, with the Shadertoy uniform
block and the
main()wrapper supplied where the paste needs them.
If you drive this API yourself, produce your own equivalent facts and send them.
The lanes are written to reconcile ground truth, not to rediscover it: counted numbers that arrive
in facts are quoted verbatim, and every GX-nnn you send comes back once in
coverage_check. Without them the reply is still a review, but nothing anchors it —
there is no list of things the model was obliged to answer for, so a plausible answer and a correct
one look the same from outside. A compile you already trust plus facts is the cheapest
quality gain available on this API, and if you are calling from a browser you can get the real one
for nothing.
The reader is deterministic and the lane is not. Keep the split: dialect detection, declaration inventory, counts, loop bounds and the compiler log belong to your parser; judgement, patches, explanation, ranking and the port belong to the lane.
Rate limits and good manners
- Poll no faster than once a second, and back off on
429. - Reuse one idempotency key per
(task, shader, attempt). A retry with the same key returns the original job rather than billing twice. /estimateis free — call it before every run, on the lane you are about to run, and compare it against/me.- Send the input object as the body. Not
{"input": {…}}, and never anX-App-Slugheader. - Clip
shaderon line boundaries, keep both ends, and mark the cut so the line numbers in the reply still mean something. - Send
factswhen you have them, and check everyGX-nnnyou sent came back incoverage_check. - Never put a token in client-side source or a repository. Read it from the environment.
Attribution
Shader Desk is a derived work built on @sickn33/shader-programming-glsl (the diagnose
lane), @bbeierle12/shader-fundamentals and @minimax-ai/shader-dev (the
explain lane), @minimax-ai/shader-dev (the optimize lane) and
@cloudai-x/threejs-shaders (the port lane). GLSL, WebGL and OpenGL ES are
specifications of the Khronos Group; Shadertoy and three.js are the property of their respective
owners. This app is not affiliated with or endorsed by any of them.