Drive Panel 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 panel-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 dashboard 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 task field comes first
This app has four lanes over one work object: an exported Grafana dashboard model.
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 the first sentence of
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 |
|---|---|---|---|---|
review | Review | Is this board production-ready as a board? | blocking[] | @wshobson/grafana-dashboards |
queries | Queries | Is every target expression correct, and what does it cost to evaluate? | queries[] | @grafana/prometheus-cardinality-troubleshooter |
portable | Portable | Will this export import into another environment and come up working? | provision{} | @grafana/datasource-provisioning |
cost | Cost | What does this board charge its owner to keep open? | budget{} | @grafana/cost-management |
One worked example per lane
Each request body below is the whole body — copy the shape, not just the fields. The
dashboard value is elided here; in a real call it is the serialised dashboard document
as one JSON string.
task: "review" — is the board production-ready?
Request body:
{"task":"review","dashboard":"{\"title\":\"Checkout — Service Overview\",\"panels\":[…]}","audience":"ops","emphasis":"general","environment":"Checkout service, Grafana 11.3 on Grafana Cloud, wall display in the ops room plus a runbook link","carryover":"","facts":["DS-002 critical: panels[4] \"Latency\" target A pins datasource uid PBFA97CFB590B2093","DS-011 warn: refresh 10s over 24 targets is 144 queries/min"]}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "rework",
"blocking": [{"item":"Rotate the Bearer token in panels[9].links[0] and delete it from the document","why":"the export is served to every viewer of the board"},
{"item":"Give panels[0] and panels[3] a unit","why":"an unlabelled number on an incident board gets read as the wrong unit"}] }
task: "queries" — is every target expression right?
Request body:
{"task":"queries","dashboard":"{\"title\":\"Checkout — Service Overview\",\"panels\":[…]}","audience":"eng","emphasis":"queries","environment":"Mimir behind one Prometheus datasource, roughly 2.4M active series","carryover":"Previous lane: review. Verdict: rework. Blocking: embedded token, missing units…","facts":["DS-004 high: panels[2] \"Memory\" target A takes rate() over a gauge metric","DS-009 high: panels[7] target B groups by pod_id"]}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "tune",
"queries": [{"panel":"panels[2] \"Memory\"","ref_id":"A","verdict":"rewrite",
"issue":"rate() is taken over a gauge, so the panel is drawing noise",
"rewrite":"avg by (pod) (container_memory_working_set_bytes{namespace=\"$namespace\"})",
"cardinality":"one series per pod, bounded by the namespace selector",
"why":"a gauge has no monotonic increase for rate() to measure"}] }
task: "portable" — will it import somewhere else?
Request body:
{"task":"portable","dashboard":"{\"title\":\"Checkout — Service Overview\",\"panels\":[…]}","audience":"eng","emphasis":"portability","environment":"promoting staging to prod, provisioned from a Git repo with Grafana 11 file provisioning","carryover":"","facts":["DS-002 critical: 7 of 9 panels pin datasource uid PBFA97CFB590B2093","DS-007 warn: no datasource-type template variable exists"]}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "pinned",
"provision": {"ready": false,
"steps": ["Add the templating entry below","Replace every literal uid with ${DS_PROM}","Null the root id and drop the __inputs wrapper"],
"blockers": ["7 panels pin a uid that does not exist in the target org"],
"variable_block": "{\"name\":\"DS_PROM\",\"type\":\"datasource\",\"query\":\"prometheus\",\"label\":\"Datasource\",\"current\":{}}",
"note": "folder placement and the alert rules that reference this board stay manual"} }
task: "cost" — what does it charge to keep open?
Request body:
{"task":"cost","dashboard":"{\"title\":\"Checkout — Service Overview\",\"panels\":[…]}","audience":"exec","emphasis":"cost","environment":"Grafana Cloud, billed on queries; the board is open on a wall display all day","carryover":"Previous lane: review. Verdict: rework…","facts":["DS-011 warn: refresh 10s over 24 targets is 144 queries/min","DS-014 note: panels[6] repeats over $pod at 12 options"]}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "expensive",
"budget": {"queries_per_minute_now": 144, "queries_per_minute_after": 24, "reduction_pct": 83,
"basis": "the reader's 24 targets at the board's own 10s refresh",
"note": "the repeat multiplier on panels[6] is a render-time factor and is counted separately, not folded in",
"wins": [{"action":"Refresh 10s to 1m","saves":"144 to 24 queries/min","effort":"low"},
{"action":"Precompute the two histogram_quantile panels as recording rules","saves":"the two most expensive targets stop scanning buckets","effort":"medium"}]} }
The input contract
These are the exact fields the web app submits — taken from its run path, not from intent.
{
"task": "review",
"dashboard": "<the exported Grafana dashboard JSON, as a STRING>",
"audience": "ops",
"emphasis": "general",
"environment": "free text: what the board is for, the Grafana version, the stack",
"carryover": "optional: the previous lane's digest",
"facts": ["DS-002 critical: panels[4] \"Latency\" target A pins datasource uid PBFA97CFB590B2093", "…"]
}
That object is the request body. Do not wrap it. A body of
{"input":{"task":"review",…}} is accepted, returns 200, creates a job and
bills it — and the model never sees task or dashboard, 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: review, queries, portable, cost. Document it first because it decides everything else. |
dashboard | string | yes | The dashboard document serialised — a JSON string, not a nested object. Max about 60 000 characters. |
audience | string | no | ops, eng, exec, mixed, unknown. Who opens the board. |
emphasis | string | no | general, clarity, portability, queries, cost. Orders findings; never hides them. |
environment | string | no | Free text: what the board is for, the Grafana version, the datasource and the stack. The portable lane also reads provisioning YAML pasted here. |
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 | array of strings | no | The deterministic reader's output. Optional, strongly recommended — see below. |
The four lane ids
review— is this board production-ready as a board: panel purpose, visualization fit, units, thresholds, reading order, variables, defaults, documentation. Not whether the queries are right.queries— is each target expression correct, and what does it cost to evaluate: rate over counters, range selectors, selector boundedness, group-by cardinality, histogram form. Judges the expressions, not the layout.portable— will this export import into a different environment and come up working, and what has to change so that it does.cost— what this board charges its owner to keep open, in query volume rather than currency, and which reductions are worth taking.
An unrecognised task is answered by the closest lane rather than rejected: an
environment that mentions provisioning gets portable, a request about
expressions gets queries, one about spend or refresh gets cost, anything
else gets review. The lane it chose is named in lane and explained in the
first sentence of overview. It will not blend two lanes' contracts — you always get
one lane block and only that lane's checks.
Clipping dashboard yourself
dashboard is capped at about 60 000 characters. Over that, the app clips
on whole-line boundaries, keeps the head and the tail, and writes a marker in-band where
the cut happened so the model knows the document it is reading is incomplete and can say so. A
caller driving this API should do the same rather than a bare truncation: cutting a dashboard
model at an arbitrary byte leaves a half-written panel object that reads as a real panel with
missing fields, and the review then reports defects that are artefacts of your cut. Keeping both
ends matters because the interesting fields live at both — panels at the top,
templating, time, refresh and the sharing wrapper at the
bottom.
facts: give the model ground truth
facts is an array of one-line strings, each a deterministic finding with a
DS-nnn id, produced by a parser rather than a model. It is optional and it changes the
quality of the answer more than any other optional field. The model is instructed to treat every
entry as true: it may add to a flag, explain it, or call it a false positive and say why,
but it may not contradict one silently. Every DS-nnn flag you send comes back
exactly once in coverage_check, in the order you sent it — that is the
contract, and it is the cheapest way to tell a real review from a plausible one. Arithmetic that
arrives in facts (target counts, queries per minute, the refresh interval) is reused
verbatim rather than recomputed.
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": "review",
"dashboard_name": "Checkout — Service Overview",
"verdict": "<one of the lane's verdict values>",
"headline": "one sentence, under 140 characters, that a person could paste into a ticket",
"overview": "2 to 4 sentences: what this dashboard is, what state it is in, what happens next",
"checks": [{"name": "<one of the lane's check names, in the lane's order>",
"status": "pass | warn | fail | n/a",
"note": "one specific sentence"}],
"findings": [{"id": "PD-001",
"severity": "critical | high | medium | low",
"title": "short, specific",
"where": "panels[4] \"p99 latency\" target B, or templating.list[1] \"namespace\", or root",
"why": "what goes wrong, in consequence terms",
"fix": "what to change, concretely",
"snippet": "the JSON or PromQL fragment to paste in, or \"\""}],
"coverage_check": [{"flag": "DS-002", "handled": true, "note": "how this lane addressed it"}],
"next_steps": ["3 to 6 imperative sentences, in the order they should be done"],
"<lane block>": "<exactly one, see the table below>"
}
findings ids run PD-001, PD-002, … in descending severity.
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, and the app
renders the canonical list and marks anything missing as not reported rather than silently
shortening it. A client should do the same.
The lane block: exactly one, chosen by task
task | verdict values | Key | Shape |
|---|---|---|---|
review |
ship / rework / rebuild |
blocking |
Array of {item, why} — what must change before this board goes in front of the stated audience. [] when there is nothing. |
queries |
sound / tune / unsafe |
queries |
Array of {panel, ref_id, verdict, issue, rewrite, cardinality, why}, one entry per target that has an expression, in panel order. Entry verdict is ok, rewrite or drop; an empty target gets ok and an issue saying so. |
portable |
portable / needs-work / pinned |
provision |
Object: {ready (bool), steps[], blockers[], variable_block (string), note}. variable_block is the exact JSON for the templating entries to add, or "". |
cost |
lean / trim / expensive |
budget |
Object: {queries_per_minute_now, queries_per_minute_after, reduction_pct, basis, note, wins[{action, saves, effort}]}. queries_per_minute_now equals the reader's figure from facts; queries_per_minute_after must be justified by the listed wins. |
Route on lane, then read that one key. The other three are absent, not null — a
client that reaches for budget on a review reply is reading a lane it did
not ask for.
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 |
|---|---|
review | panel purpose, visualization fit, units and decimals, thresholds and colour, reading order, row structure, template variables, time range and refresh, annotations and links, documentation |
queries | rate and counter correctness, range selector, selector boundedness, aggregation shape, group-by cardinality, histogram form, recording rule opportunities, legend labels, query count per panel, expensive constructs |
portable | datasource references, datasource variable, variable completeness, identity fields, sharing wrapper, plugin dependencies, hardcoded environment, library panels and dependencies, provisioning form, secrets |
cost | refresh interval, default time range, panel and target budget, maxDataPoints and interval, repeat multiplication, shared and duplicated queries, precomputation, series volume, alerting overlap, retention and range fit |
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
panel-deskand shows it once. This is the one that spends your credits and sees your review history. - A guest token —
POST /v1/app-api/guestwith{"slug":"panel-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://panel-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":"panel-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("PANEL_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": "panel-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: "panel-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("PANEL_DESK_TOKEN"); t != "" {
return t
}
return "YOUR_TOKEN"
}
func mintGuest() (string, error) {
body, _ := json.Marshal(map[string]string{"slug": "panel-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("PANEL_DESK_TOKEN") != null ? System.getenv("PANEL_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\":\"panel-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["PANEL_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" => "panel-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("PANEL_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" => "panel-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("PANEL_DESK_TOKEN") ?? "YOUR_TOKEN";
static async Task<string> MintGuest() {
using var anon = new HttpClient();
var res = await anon.PostAsJsonAsync(Base + "/guest", new { slug = "panel-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"
pd() { # pd GET /me | pd 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":"panel-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 review 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 — a queries reply carries one entry per target and a
portable reply carries a variable block, so their ceilings are not the same. An
estimate for review does not price cost. 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":"review","dashboard":"{\"title\":\"Checkout — Service Overview\",\"panels\":[]}","audience":"ops","emphasis":"general","environment":"Grafana 11.3, ops wall display","carryover":"","facts":[]}'
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":8400,"min_credits":8400,"sponsor_enabled":false}}
inp = {"task": "review", "dashboard": dashboard_text, "audience": "ops",
"emphasis": "general", "environment": "Grafana 11.3, ops wall display",
"carryover": "", "facts": facts}
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: "review", dashboard: dashboardText, audience: "ops", emphasis: "general",
environment: "Grafana 11.3, ops wall display", carryover: "", facts
};
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": "review", "dashboard": dashboardText, "audience": "ops",
"emphasis": "general", "environment": "Grafana 11.3, ops wall display",
"carryover": "", "facts": facts,
}
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; dashboard is a STRING, not an object.
String body = "{\"task\":\"review\",\"dashboard\":" + jsonQuoted(dashboardText)
+ ",\"audience\":\"ops\",\"emphasis\":\"general\""
+ ",\"environment\":\"Grafana 11.3, ops wall display\""
+ ",\"carryover\":\"\",\"facts\":[]}";
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" => "review", "dashboard" => dashboard_text, "audience" => "ops",
"emphasis" => "general", "environment" => "Grafana 11.3, ops wall display",
"carryover" => "", "facts" => facts }
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" => "review", "dashboard" => $dashboardText, "audience" => "ops",
"emphasis" => "general", "environment" => "Grafana 11.3, ops wall display",
"carryover" => "", "facts" => $facts];
$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 = "review", dashboard = dashboardText, audience = "ops", emphasis = "general",
environment = "Grafana 11.3, ops wall display", carryover = "", facts
};
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 review 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, dashboard, attempt). The lane belongs in the key because two lanes over
the same dashboard are two distinct runs and must not collide; the dashboard belongs in it because
the same lane over a changed board is a new run; the attempt counter belongs in it because a
deliberate re-run of an identical input is a second review 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 review, and nothing downstream will tell you it happened:
you will simply have two jobs and one answer you wanted.
BODY='{"task":"review","dashboard":"{\"title\":\"Checkout — Service Overview\",\"panels\":[]}","audience":"ops","emphasis":"general","environment":"Grafana 11.3, ops wall display","carryover":"","facts":[]}'
# The key: lane, a hash of the dashboard, and the attempt number.
DIGEST=$(printf '%s' "review$BODY" | shasum -a 256 | cut -c1-12)
KEY="panel-desk:review:$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, dashboard, attempt) -> one stable key. Reuse it on every retry."""
digest = hashlib.sha256(
(inp["task"] + "\x00" + inp["dashboard"]).encode()).hexdigest()[:12]
return f"panel-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"])
review = json.loads(job["output"]["output"])
print(review["lane"], review["verdict"], job.get("charged_credits"))
import { createHash } from "node:crypto";
// (task, dashboard, attempt) -> one stable key. Reuse it on every retry.
function idemKey(input, attempt = 1) {
const digest = createHash("sha256")
.update(`${input.task}\u0000${input.dashboard}`)
.digest("hex")
.slice(0, 12);
return `panel-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 review = JSON.parse(job.output.output);
console.log(review.lane, review.verdict, job.charged_credits);
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
// (task, dashboard, attempt) -> one stable key. Reuse it on every retry.
func idemKey(task, dashboard string, attempt int) string {
sum := sha256.Sum256([]byte(task + "\x00" + dashboard))
return fmt.Sprintf("panel-desk:%s:%s:a%d", task, hex.EncodeToString(sum[:])[:12], attempt)
}
key := idemKey("review", dashboardText, 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 review JSON
import java.security.MessageDigest;
// (task, dashboard, attempt) -> one stable key. Reuse it on every retry.
static String idemKey(String task, String dashboard, int attempt) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256")
.digest((task + "\0" + dashboard).getBytes("UTF-8"));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 6; i++) hex.append(String.format("%02x", d[i]));
return "panel-desk:" + task + ":" + hex + ":a" + attempt;
}
String key = idemKey("review", dashboardText, 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, dashboard, attempt) -> one stable key. Reuse it on every retry.
def idem_key(input, attempt = 1)
digest = Digest::SHA256.hexdigest("#{input['task']}\0#{input['dashboard']}")[0, 12]
"panel-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"
review = JSON.parse(job.dig("output", "output"))
puts review["lane"], review["verdict"]
<?php
// (task, dashboard, 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["dashboard"]), 0, 12);
return "panel-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"]);
}
$review = json_decode($job["output"]["output"], true);
echo $review["lane"], " ", $review["verdict"], PHP_EOL;
using System.Security.Cryptography;
using System.Text;
// (task, dashboard, attempt) -> one stable key. Reuse it on every retry.
static string IdemKey(string task, string dashboard, int attempt = 1) {
var d = SHA256.HashData(Encoding.UTF8.GetBytes(task + "\0" + dashboard));
return $"panel-desk:{task}:{Convert.ToHexString(d)[..12].ToLowerInvariant()}:a{attempt}";
}
var key = IdemKey("review", dashboardText);
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 review = JsonDocument.Parse(
job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(review.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 staged progress card can advance on section markers as they arrive. 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\":\"review\","}
# data: {"type":"delta","text":"\"dashboard_name\":\"Checkout"}
# ...
# data: {"type":"job","job_id":"job_123","status":"succeeded","charged_credits":2140,
# "truncated":false,"output":{"output":"{...the whole review...}"}}
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_review = 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 rawReview = 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.
blocking,queries,provision,budget— 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 becausenext_stepsnever landed wastes a credit you have already spent.
JOB=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/job_123" \
-H "Authorization: Bearer $TOKEN")
# The review is a JSON string inside the job. Unwrap it once, then read it.
REVIEW=$(echo "$JOB" | jq -r '.data.output.output')
echo "$REVIEW" | jq -r '.lane, .verdict, .headline'
echo "$REVIEW" | jq -r '.checks[] | "\(.status)\t\(.name)"'
echo "$REVIEW" | jq -r '.coverage_check[] | "\(.flag)\thandled=\(.handled)"'
# The lane block: exactly one of these is present.
echo "$REVIEW" | jq '.blocking // .queries // .provision // .budget'
# Was it cut short?
echo "$JOB" | jq '.data.truncated, .data.charged_credits'
def parse_review(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])
review = parse_review(job["output"]["output"])
LANE_BLOCK = {"review": "blocking", "queries": "queries",
"portable": "provision", "cost": "budget"}
lane = review["lane"] # route on this, not on inp["task"]
block = review.get(LANE_BLOCK[lane])
print(review["verdict"], "-", review["headline"])
for c in review.get("checks", []):
print(f" [{c['status']:4}] {c['name']}: {c['note']}")
for f in review.get("findings", []):
print(f" {f['id']} {f['severity']}: {f['title']} @ {f['where']}")
for cc in review.get("coverage_check", []):
print(f" {cc['flag']} handled={cc['handled']}: {cc['note']}")
if lane == "cost":
print(block["queries_per_minute_now"], "->", block["queries_per_minute_after"])
elif lane == "portable":
print("ready" if block["ready"] else "blockers: " + "; ".join(block["blockers"]))
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 = { review: "blocking", queries: "queries",
portable: "provision", cost: "budget" };
function parseReview(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 review = parseReview(rawReview);
const block = review[LANE_BLOCK[review.lane]]; // route on review.lane
console.log(review.verdict, "-", review.headline);
review.checks?.forEach(c => console.log(` [${c.status}] ${c.name}: ${c.note}`));
review.findings?.forEach(f => console.log(` ${f.id} ${f.severity}: ${f.title} @ ${f.where}`));
review.coverage_check?.forEach(cc => console.log(` ${cc.flag} handled=${cc.handled}`));
if (review.lane === "queries") {
block.filter(q => q.verdict !== "ok")
.forEach(q => console.log(`${q.panel} ${q.ref_id}: ${q.rewrite}`));
}
if (job?.truncated) console.warn("reply cut short - rendering the sections that parsed");
var laneBlock = map[string]string{
"review": "blocking", "queries": "queries",
"portable": "provision", "cost": "budget",
}
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 review map[string]any
if err := json.Unmarshal([]byte(raw), &review); err != nil {
// Truncated: render whatever the caller already streamed rather than dropping it.
log.Printf("reply not parseable in full: %v", err)
}
lane, _ := review["lane"].(string)
block := review[laneBlock[lane]]
fmt.Println(review["verdict"], review["headline"], lane)
for _, c := range review["checks"].([]any) {
m := c.(map[string]any)
fmt.Printf(" [%v] %v: %v\n", m["status"], m["name"], m["note"])
}
if lane == "cost" {
b := block.(map[string]any)
fmt.Println(b["queries_per_minute_now"], "->", b["queries_per_minute_after"])
}
if t, _ := job["truncated"].(bool); t {
fmt.Println("reply was cut short")
}
// laneBlock: review -> blocking, queries -> queries,
// portable -> provision, cost -> budget
static final Map<String, String> LANE_BLOCK = Map.of(
"review", "blocking", "queries", "queries",
"portable", "provision", "cost", "budget");
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 review = mapper.readTree(sliceObject(outputOutput));
// String lane = review.get("lane").asText(); // route on this
// var block = review.get(LANE_BLOCK.get(lane)); // exactly one is present
// for (var c : review.withArray("checks")) { ... }
// for (var cc : review.withArray("coverage_check")) { ... }
// If the parse throws, render the fields you already have rather than
// discarding the run: job.truncated == true is the expected cause.
LANE_BLOCK = { "review" => "blocking", "queries" => "queries",
"portable" => "provision", "cost" => "budget" }.freeze
def parse_review(raw)
start = raw.index("{")
raise "no JSON object in the reply" unless start
JSON.parse(raw[start..raw.rindex("}")])
end
review = parse_review(job.dig("output", "output"))
block = review[LANE_BLOCK[review["lane"]]] # route on review["lane"]
puts "#{review['verdict']} - #{review['headline']}"
review.fetch("checks", []).each { |c| puts " [#{c['status']}] #{c['name']}: #{c['note']}" }
review.fetch("findings", []).each { |f| puts " #{f['id']} #{f['severity']}: #{f['title']}" }
review.fetch("coverage_check", []).each { |cc| puts " #{cc['flag']} #{cc['handled']}" }
case review["lane"]
when "portable" then puts block["variable_block"]
when "cost" then puts "#{block['queries_per_minute_now']} -> #{block['queries_per_minute_after']}"
end
warn "reply cut short - rendering what parsed" if job["truncated"]
<?php
const LANE_BLOCK = ["review" => "blocking", "queries" => "queries",
"portable" => "provision", "cost" => "budget"];
function parse_review(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;
}
$review = parse_review($job["output"]["output"]);
$block = $review[LANE_BLOCK[$review["lane"]]] ?? null; // route on lane
printf("%s - %s\n", $review["verdict"], $review["headline"]);
foreach ($review["checks"] ?? [] as $c) {
printf(" [%s] %s: %s\n", $c["status"], $c["name"], $c["note"]);
}
foreach ($review["coverage_check"] ?? [] as $cc) {
printf(" %s handled=%s\n", $cc["flag"], $cc["handled"] ? "true" : "false");
}
if (!empty($job["truncated"])) {
error_log("reply cut short - rendering the sections that parsed");
}
var laneBlock = new Dictionary<string, string> {
["review"] = "blocking", ["queries"] = "queries",
["portable"] = "provision", ["cost"] = "budget"
};
static JsonElement ParseReview(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 review = ParseReview(job.GetProperty("output").GetProperty("output").GetString()!);
var lane = review.GetProperty("lane").GetString()!; // route on this
var block = review.GetProperty(laneBlock[lane]);
Console.WriteLine($"{review.GetProperty("verdict")} - {review.GetProperty("headline")}");
foreach (var c in review.GetProperty("checks").EnumerateArray())
Console.WriteLine($" [{c.GetProperty("status")}] {c.GetProperty("name")}");
foreach (var cc in review.GetProperty("coverage_check").EnumerateArray())
Console.WriteLine($" {cc.GetProperty("flag")} {cc.GetProperty("handled")}");
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 parses the dashboard model in the browser. That reader is the
whole of what this app does for free: no account, no token, no job, nothing uploaded. It walks the
panels and the panels nested inside collapsed rows, every target and its refId, the
template variables and whether each is actually referenced, every literal datasource uid, the
24-column layout grid, credential-shaped values, and the refresh arithmetic — target count times
sixty over the refresh seconds, with the repeat multiplier named separately rather than folded in.
Two things come out of it, and both are free:
- The
factsarray — one line per deterministic finding, each with aDS-nnnid. This is what you put in thefactsfield. - A patched
dashboard.json— datasource pins replaced with a variable and the variable added when missing, rootidnulled, sharing wrapper and snapshot data stripped, legacy per-panel alerts deleted, duplicate panel ids renumbered,graphmigrated totimeseries, bare-string datasource references converted to the object form, credential-shaped values redacted. Every change is listed before download; ambiguous cases are left alone with a reason.
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: arithmetic that arrives in
facts is reused verbatim, and every DS-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 parser you already trust plus facts is the cheapest
quality gain available on this API.
The reader is deterministic and the lane is not. Keep the split: counts, ids, uids, refresh maths and credential detection belong to your parser; judgement, rewrites, ranking and the provisioning plan 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, dashboard, 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
dashboardon line boundaries, keep both ends, and mark the cut. - Never put a token in client-side source or a repository. Read it from the environment.
Attribution
Panel Desk is a derived work built on @wshobson/grafana-dashboards (the review lane),
@grafana/prometheus-cardinality-troubleshooter (the queries lane),
@grafana/datasource-provisioning (the portable lane) and
@grafana/cost-management (the cost lane). Grafana, Prometheus, Loki and Mimir are
trademarks of their respective owners; this app is not affiliated with or endorsed by Grafana Labs.