← Panel Desk / API
Token panel

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

CodeHTTPWhat it meansWhat to do
UNAUTHORIZED401Missing, malformed or expired token.Mint a new one from the token panel.
FORBIDDEN403The 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_CREDITS402Balance is under min_credits for this run.Compare /me against /estimate before running.
VALIDATION_ERROR400The input object is the wrong shape.Check error.details; task and dashboard are required.
NOT_FOUND404No such route, or no such job_id for this subject.Check the path and that the job was created by this token.
RATE_LIMITED429Too many requests.Back off; do not tight-loop a poll.
INTERNAL500Something 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.

taskLaneAnswersLane blockSource skill
reviewReviewIs this board production-ready as a board?blocking[]@wshobson/grafana-dashboards
queriesQueriesIs every target expression correct, and what does it cost to evaluate?queries[]@grafana/prometheus-cardinality-troubleshooter
portablePortableWill this export import into another environment and come up working?provision{}@grafana/datasource-provisioning
costCostWhat 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.

FieldTypeRequiredNotes
taskstringyesOne of exactly four lane ids: review, queries, portable, cost. Document it first because it decides everything else.
dashboardstringyesThe dashboard document serialised — a JSON string, not a nested object. Max about 60 000 characters.
audiencestringnoops, eng, exec, mixed, unknown. Who opens the board.
emphasisstringnogeneral, clarity, portability, queries, cost. Orders findings; never hides them.
environmentstringnoFree text: what the board is for, the Grafana version, the datasource and the stack. The portable lane also reads provisioning YAML pasted here.
carryoverstringnoA digest of the previous lane's conclusion. The model builds on it, acknowledges it, and does not re-litigate it.
factsarray of stringsnoThe deterministic reader's output. Optional, strongly recommended — see below.

The four lane ids

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

taskverdict valuesKeyShape
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.

Lanechecks[].name, in order
reviewpanel purpose, visualization fit, units and decimals, thresholds and colour, reading order, row structure, template variables, time range and refresh, annotations and links, documentation
queriesrate 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
portabledatasource references, datasource variable, variable completeness, identity fields, sharing wrapper, plugin dependencies, hardcoded environment, library panels and dependencies, provisioning form, secrets
costrefresh 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:

Keep the token out of your source. Read it from the environment at runtime and never commit it.

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.

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.

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:

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.

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.

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:

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.

7. Parse the result

Four moves, in this order, whichever route you took:

  1. 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 of output.output normally 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.
  2. Route on lane, not on what you asked for. An unrecognised task is answered by the nearest lane, and that lane is what lane says.
  3. Read that lane's block and nothing else. blocking, queries, provision, budget — exactly one is present.
  4. 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, overview and the first checks arrive early and are worth showing; throwing the whole run away because next_steps never landed wastes a credit you have already spent.

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:

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

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.