Drive Switch Desk from your own code
Everything the web page does is available over HTTP: post one device's own show output and get
back either a configuration review with the exact ordered command lines to fix each defect and a
matching rollback, or a per-interface health read that names the layer each port is failing at.
The natural uses are a nightly job that walks a config archive and opens a ticket per device that
drifted from the baseline, and a pre-change gate in a pipeline that refuses to push a config whose
review comes back exposed.
The browser and the API send the same input object. The one thing the browser does that you do not have to reproduce is the free local prescan — see prescan, honestly below.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
Send your token as Authorization: Bearer aut_… and nothing else. There is
no app-slug header: an app-user token already binds the pair {app, subject}, so the
token alone says which app you are calling and who you are. The only call that names the app in its
body is the guest mint.
One shape mistake is worth naming because it fails silently: the request body is
the input object. Do not wrap it in {"input": {…}} — that returns 200 with a
plausible-looking price while hiding task from the model, so you get whichever lane it
guessed.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate for the lane you are about to run, then top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. Sign in for a personal token, or ask the owner to sponsor usage. |
not_found | 404 | Unknown job id, or the app slug in the guest mint does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | The input object is missing a required field — device_output is the usual one — or a field is the wrong type. A body that is not valid JSON at all comes back as a 400. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop a poll. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
Start with task: it picks the lane
task is the first field to decide and the only one that changes the shape of the
answer. It is the lane router. Two lanes read the same paste and return the same envelope, then
diverge completely in the body — a caller that reads findings[] out of a
health reply finds an empty array, and a caller that reads interfaces[]
out of a config reply finds the same. Send it explicitly on every call; the reply
echoes it back in task, and that echo is what you branch on.
task | the question it answers | the body it adds | posture values |
|---|---|---|---|
config |
What is wrong with how this device is configured? Every defect with a severity, an area, the interface or ACL it lives on, the evidence, the impact, the exact ordered command lines that fix it, a rollback for each one, how risky the fix is and whether it needs a maintenance window — plus a verdict on every access list and an ordered change plan whose verification steps are read-only. | findings[], acl_review[], change_plan[] |
hardened, needs-hardening, exposed |
health |
Which interfaces are actually failing, and how? A grade and a layer per interface in the show output, the mechanism rather than the symptom, the counters that establish it, and the next physical or configuration action — plus symptoms grouped into root causes each with one read-only test that tells it apart from its nearest rival, and a watch list of what is not broken yet. | interfaces[], root_causes[], watch_list[] |
healthy, degraded, failing, mixed |
The lanes are complementary, not alternatives. Run health first when something is
broken now and config first when nothing is broken and an audit is coming; then carry
the result across in handoff so the second run starts where the first stopped rather
than re-deriving the device. Two lanes over one paste are two metered runs.
The input object
| field | type | required | what it is |
|---|---|---|---|
task | string | yes | "config" or "health". If absent the model picks the closer lane from what you sent and names its choice in assumptions — convenient interactively, never what you want in a script. |
device_output | string | yes | The device's own output: a show running-config, a show interfaces, or both concatenated in either order. Prompt lines and show echoes are tolerated. The browser sends at most 22,000 characters, cut on whole config and interface blocks. |
hostname | string | no | Taken from the config when present. Sent separately so a paste that is only counters still names the device. |
role | string | no | One of unknown, access, distribution, branch-router, wan-edge, dc-leaf, lab. Changes which findings matter: DHCP snooping is a real gap on an access switch and noise on a lab box. |
goal | string | no | One of harden, troubleshoot, handover, change, baseline. Changes the ordering, not the content. |
context | string | no | Free text, up to 4,000 characters. What users complain about, what changed recently, what you may not touch. This moves the answer more than any other optional field. |
prescan | object | no, strongly recommended | The deterministic facts. See below. |
omitted | object | no | {interfaces[], counters[], note} — blocks whose raw lines were dropped to fit the budget but whose facts are still in prescan. |
handoff | object | no | {from, posture, interfaces[], notes[]} from the other lane's answer. |
retry_note | string | no | Used by the page's one automatic reformat retry. You will not normally send it. |
Prescan, honestly
prescan is the output of a parser and a pile of arithmetic that runs in the browser
before the model is asked anything. It carries the config hierarchy, the subnet arithmetic for
every addressed interface, every access-list rule normalised to prefixes with the shadowing already
proved, error and drop rates per million frames, and the cross-reference between the two halves of
the paste.
It is optional and it is not free to skip. The prompt asks the model to reconcile
every prescan.facts.flags[].id in its coverage_check, so a request with
no prescan produces an answer with nothing to be held against — and the facts are priced, so a
request without them is measurably cheaper and measurably weaker. If you are driving this from a
script over an archive of configs, the honest options are to send no prescan and accept a softer
answer, or to reproduce the parts you care about. The shape is:
"prescan": {
"facts": {
"flags": [ { "id": "LATE-COLLISIONS", "severity": "critical", "label": "...", "why": "..." } ],
"resources": [ { "id": "INTERFACES", "label": "7 interface blocks: 4 physical, 3 logical, 0 shut" } ]
},
"device": { "hostname": "sw-acc-03", "version": "15.2" },
"score": 10,
"halves": { "config": true, "show": true },
"subnets": [ { "interface": "Vl10", "address": "10.10.10.1/24", "network": "10.10.10.0/24",
"usable": "10.10.10.1 - 10.10.10.254", "hosts": 254, "vrf": null } ],
"overlaps": [ { "a": "Vl10 10.10.10.0/24", "b": "Vl20 10.10.10.128/25" } ],
"acls": [ { "name": "USER-OUT", "type": "extended", "defined": true, "rules": 4,
"applied": [], "has_explicit_deny": true, "logs_deny": false,
"shadowed": [ { "seq": 2, "kind": "redundant", "covered_by": 1,
"rule": "permit ip 10.10.10.64 0.0.0.63 any" } ] } ],
"interfaces": [ { "name": "Gi1/0/24", "description": "LEGACY link to old hub", "mode": "access",
"cfg_speed": "100", "cfg_duplex": "half", "portfast": false } ],
"counters": [ { "name": "Gi1/0/24", "grade": "failing", "duplex": "half", "speed": "100Mb/s",
"in_packets": 40219, "crc": 18401, "crc_per_million": 457520.08,
"late_collision": 921, "output_drops": 88214 } ],
"globals": { "aaa": false, "http_server": true, "snmp_rw": 1, "ssh_version": 0 },
"vlans": [ { "id": "10", "name": "USERS" } ],
"vty": [ { "kind": "vty", "range": "0 4", "transport_input": null, "access_class": null } ],
"cross_reference": { "configured_not_shown": [], "shown_not_configured": [] },
"secrets_present": [ "enable password, type 7", "SNMP community string" ]
}
Note what is not in there: no credential values. The prescan reports that a type-7 secret exists and where, and never its bytes.
Step 1 — get a token
A guest token is minted with one unauthenticated POST and is enough for
/me and /estimate. The body field is slug — verified
against the live endpoint; app_slug is rejected with
400 invalid_request "slug is required". A metered run needs a personal token,
which you get by signing in on the token page and copying it — that
page also prints a ready-made shell export. Keep tokens out of source control; read them from a
secret store or a config file at runtime.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H "Content-Type: application/json" \
-d '{"slug":"switch-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest",...}}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
def guest_token():
req = urllib.request.Request(
BASE + "/guest",
data=json.dumps({"slug": "switch-desk"}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]["token"]
TOKEN = guest_token() # or paste a personal token from /tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
async function guestToken() {
const r = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "switch-desk" }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error.message);
return j.data.token;
}
const TOKEN = await guestToken(); // or a personal token from /tokens.html
package main
import (
"bytes"
"encoding/json"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
} `json:"error"`
}
func guestToken() (string, error) {
body, _ := json.Marshal(map[string]string{"slug": "switch-desk"})
resp, err := http.Post(base+"/guest", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer resp.Body.Close()
var e envelope
if err := json.NewDecoder(resp.Body).Decode(&e); err != nil {
return "", err
}
var d struct{ Token string `json:"token"` }
if err := json.Unmarshal(e.Data, &d); err != nil {
return "", err
}
return d.Token, nil
}
import java.net.URI;
import java.net.http.*;
class SwitchDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String guestToken() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"switch-desk\"}"))
.build();
String body = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// any JSON library will do; the token is data.token
int i = body.indexOf("\"token\":\"") + 9;
return body.substring(i, body.indexOf('"', i));
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
def guest_token
uri = URI("#{BASE}/guest")
res = Net::HTTP.post(uri, { slug: "switch-desk" }.to_json,
"Content-Type" => "application/json")
JSON.parse(res.body).dig("data", "token")
end
TOKEN = guest_token # or a personal token from /tokens.html
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
function post_json(string $path, array $body, ?string $token = null): array {
$headers = ["Content-Type: application/json"];
if ($token !== null) { $headers[] = "Authorization: Bearer $token"; }
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_RETURNTRANSFER => true,
]);
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
return $out;
}
$token = post_json("/guest", ["slug" => "switch-desk"])["data"]["token"];
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
var http = new HttpClient();
async Task<string> GuestTokenAsync()
{
var res = await http.PostAsJsonAsync($"{Base}/guest", new { slug = "switch-desk" });
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("data").GetProperty("token").GetString()!;
}
var token = await GuestTokenAsync(); // or a personal token from /tokens.html
Step 2 — check the session with /me
GET /me is free and is how you tell a personal token from a guest one before you try to
spend anything. It returns subject_type (user or guest),
username for a personal token, and credits.
curl -s https://api.skillsafe.ai/v1/app-api/me -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","username":"you","credits":184210}}
def get(path, token):
req = urllib.request.Request(BASE + path, headers={"Authorization": "Bearer " + token})
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
me = get("/me", TOKEN)
print(me["subject_type"], me.get("credits"))
async function api(path, token, body) {
const r = await fetch(BASE + path, {
method: body ? "POST" : "GET",
headers: {
"Authorization": `Bearer ${token}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const j = await r.json();
if (!j.ok) throw Object.assign(new Error(j.error.message), j.error);
return j.data;
}
const me = await api("/me", TOKEN);
console.log(me.subject_type, me.credits);
func apiGet(token, path string, out interface{}) error {
req, _ := http.NewRequest("GET", base+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var e envelope
if err := json.NewDecoder(resp.Body).Decode(&e); err != nil {
return err
}
return json.Unmarshal(e.Data, out)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = apiGet(token, "/me", &me)
static String apiGet(String token, String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + token)
.GET().build();
return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
System.out.println(apiGet(token, "/me"));
def api_get(path, token)
uri = URI("#{BASE}#{path}")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{token}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body).fetch("data")
end
me = api_get("/me", TOKEN)
puts "#{me['subject_type']} #{me['credits']}"
function get_json(string $path, string $token): array {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
CURLOPT_RETURNTRANSFER => true,
]);
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
return $out["data"];
}
$me = get_json("/me", $token);
echo $me["subject_type"], " ", $me["credits"] ?? 0, PHP_EOL;
async Task<JsonElement> ApiGetAsync(string token, string path)
{
var req = new HttpRequestMessage(HttpMethod.Get, Base + path);
req.Headers.Add("Authorization", $"Bearer {token}");
var res = await http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("data").Clone();
}
var me = await ApiGetAsync(token, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Step 3 — price it with /estimate
POST /estimate costs nothing, starts no job, and returns the price and the model
binding: model, model_alias, markup_bps,
hold_credits, min_credits and sponsor_enabled. Call it per
lane — the two lanes have different prompts and different output caps, so
hold_credits differs and showing one lane's number for the other is simply wrong.
hold_credits is what gets reserved, priced against the full output cap. The
charge is almost always far lower. If your balance sits between min_credits and
hold_credits the run still executes with a reduced cap and comes back
truncated: true — handle that as "cut short, top up" rather than presenting a
clipped answer as complete.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"task": "config",
"device_output": "hostname sw-acc-03\n!\ninterface GigabitEthernet1/0/24\n description LEGACY link to old hub\n switchport mode access\n duplex half\n speed 100\n!\nend",
"hostname": "sw-acc-03",
"role": "access",
"goal": "harden",
"context": "Inherited access switch; users behind Gi1/0/24 complain of slow transfers."
}'
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":4213,"min_credits":421,"sponsor_enabled":false}}
def post(path, token, body):
req = urllib.request.Request(
BASE + path, data=json.dumps(body).encode(),
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
payload = {
"task": "config",
"device_output": "hostname sw-acc-03\n!\ninterface GigabitEthernet1/0/24\n description LEGACY link to old hub\n switchport mode access\n duplex half\n speed 100\n!\nend",
"hostname": "sw-acc-03",
"role": "access",
"goal": "harden",
"context": "Inherited access switch; users behind Gi1/0/24 complain of slow transfers."
}
est = post("/estimate", TOKEN, payload)
print(est["hold_credits"], "reserved;", est["min_credits"], "minimum")
const payload = {
"task": "config",
"device_output": "hostname sw-acc-03\n!\ninterface GigabitEthernet1/0/24\n description LEGACY link to old hub\n switchport mode access\n duplex half\n speed 100\n!\nend",
"hostname": "sw-acc-03",
"role": "access",
"goal": "harden",
"context": "Inherited access switch; users behind Gi1/0/24 complain of slow transfers."
};
const est = await api("/estimate", TOKEN, payload);
console.log(est.hold_credits, "reserved;", est.min_credits, "minimum");
func apiPost(token, path string, body, out interface{}) error {
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", base+path, bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var e envelope
if err := json.NewDecoder(resp.Body).Decode(&e); err != nil {
return err
}
if !e.OK {
return fmt.Errorf("%s: %s", e.Error.Code, e.Error.Message)
}
return json.Unmarshal(e.Data, out)
}
payload := map[string]any{
"task": "config",
"device_output": deviceOutput,
"hostname": "sw-acc-03",
"role": "access",
"goal": "harden",
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
_ = apiPost(token, "/estimate", payload, &est)
static String apiPost(String token, String path, String jsonBody) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
String payload = """
{
"task": "config",
"device_output": "hostname sw-acc-03\n!\ninterface GigabitEthernet1/0/24\n description LEGACY link to old hub\n switchport mode access\n duplex half\n speed 100\n!\nend",
"hostname": "sw-acc-03",
"role": "access",
"goal": "harden",
"context": "Inherited access switch; users behind Gi1/0/24 complain of slow transfers."
}
""";
System.out.println(apiPost(token, "/estimate", payload));
def api_post(path, token, body)
uri = URI("#{BASE}#{path}")
req = Net::HTTP::Post.new(uri,
"Authorization" => "Bearer #{token}",
"Content-Type" => "application/json")
req.body = body.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
parsed = JSON.parse(res.body)
raise parsed.dig("error", "message") unless parsed["ok"]
parsed.fetch("data")
end
payload = JSON.parse(<<~JSON)
{
"task": "config",
"device_output": "hostname sw-acc-03\n!\ninterface GigabitEthernet1/0/24\n description LEGACY link to old hub\n switchport mode access\n duplex half\n speed 100\n!\nend",
"hostname": "sw-acc-03",
"role": "access",
"goal": "harden",
"context": "Inherited access switch; users behind Gi1/0/24 complain of slow transfers."
}
JSON
est = api_post("/estimate", TOKEN, payload)
puts "#{est['hold_credits']} reserved; #{est['min_credits']} minimum"
$payload = json_decode(<<<'JSON'
{
"task": "config",
"device_output": "hostname sw-acc-03\n!\ninterface GigabitEthernet1/0/24\n description LEGACY link to old hub\n switchport mode access\n duplex half\n speed 100\n!\nend",
"hostname": "sw-acc-03",
"role": "access",
"goal": "harden",
"context": "Inherited access switch; users behind Gi1/0/24 complain of slow transfers."
}
JSON, true);
$est = post_json("/estimate", $payload, $token)["data"];
printf("%d reserved; %d minimum\n", $est["hold_credits"], $est["min_credits"]);
async Task<JsonElement> ApiPostAsync(string token, string path, object body)
{
var req = new HttpRequestMessage(HttpMethod.Post, Base + path)
{
Content = JsonContent.Create(body)
};
req.Headers.Add("Authorization", $"Bearer {token}");
var res = await http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean())
throw new Exception(doc.RootElement.GetProperty("error").GetProperty("message").GetString());
return doc.RootElement.GetProperty("data").Clone();
}
var payload = new
{
task = "config",
device_output = deviceOutput,
hostname = "sw-acc-03",
role = "access",
goal = "harden"
};
var est = await ApiPostAsync(token, "/estimate", payload);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Step 4 — run it and poll
POST /run returns {job_id} immediately; poll
GET /jobs/{job_id} until status is terminal. The model's text arrives as
output.output — a JSON string, which you then parse.
Always send Idempotency-Key, and put the lane in it. Two lanes over
the same device output are two distinct runs and must not collide on one key. Hash
(task, device_output, attempt). On a 5xx, retry with the SAME key: that is what stops
a network blip from billing you twice.
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: switch-desk:config:$(printf %s "$DEVICE_OUTPUT" | shasum | cut -c1-16):a1" \
-d "$PAYLOAD" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# poll until terminal - never tight-loop
while :; do
S=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
echo "$S" | grep -q '"status":"succeeded"' && break
echo "$S" | grep -qE '"status":"(failed|cancelled)"' && { echo "$S"; exit 1; }
sleep 2
done
echo "$S" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["output"]["output"])'
# stdout is the JSON object described in "The output contract" below
import hashlib, time
def idem(payload, attempt=1):
h = hashlib.sha256(payload["device_output"].encode()).hexdigest()[:16]
return f"switch-desk:{payload['task']}:{h}:a{attempt}"
def run_and_wait(payload, token, timeout=180):
req = urllib.request.Request(
BASE + "/run", data=json.dumps(payload).encode(),
headers={"Authorization": "Bearer " + token,
"Content-Type": "application/json",
"Idempotency-Key": idem(payload)},
method="POST")
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
deadline = time.time() + timeout
while time.time() < deadline:
job = get("/jobs/" + job_id, token)
if job["status"] == "succeeded":
return json.loads(job["output"]["output"])
if job["status"] in ("failed", "cancelled"):
raise RuntimeError(job.get("error") or job["status"])
time.sleep(2) # never tight-loop
raise TimeoutError(job_id)
result = run_and_wait(payload, TOKEN)
for f in result["findings"]:
print(f["severity"].upper(), f["id"], f["target"], "-", f["title"])
for line in f["fix_lines"]:
print(" ", line)
import { createHash } from "node:crypto";
function idem(payload, attempt = 1) {
const h = createHash("sha256").update(payload.device_output).digest("hex").slice(0, 16);
return `switch-desk:${payload.task}:${h}:a${attempt}`;
}
async function runAndWait(payload, token, timeoutMs = 180000) {
const r = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
"Idempotency-Key": idem(payload),
},
body: JSON.stringify(payload),
});
const { data } = await r.json();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const job = await api(`/jobs/${data.job_id}`, token);
if (job.status === "succeeded") return JSON.parse(job.output.output);
if (job.status === "failed" || job.status === "cancelled") throw new Error(job.status);
await new Promise((res) => setTimeout(res, 2000)); // never tight-loop
}
throw new Error("timed out");
}
const result = await runAndWait(payload, TOKEN);
for (const f of result.findings) {
console.log(f.severity.toUpperCase(), f.id, f.target, "-", f.title);
f.fix_lines.forEach((l) => console.log(" ", l));
}
func idem(task, deviceOutput string, attempt int) string {
sum := sha256.Sum256([]byte(deviceOutput))
return fmt.Sprintf("switch-desk:%s:%x:a%d", task, sum[:8], attempt)
}
func runAndWait(token string, payload map[string]any) (map[string]any, error) {
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key",
idem(payload["task"].(string), payload["device_output"].(string), 1))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var e envelope
json.NewDecoder(resp.Body).Decode(&e)
var started struct{ JobID string `json:"job_id"` }
json.Unmarshal(e.Data, &started)
deadline := time.Now().Add(3 * time.Minute)
for time.Now().Before(deadline) {
var job struct {
Status string `json:"status"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
if err := apiGet(token, "/jobs/"+started.JobID, &job); err != nil {
return nil, err
}
switch job.Status {
case "succeeded":
var out map[string]any
return out, json.Unmarshal([]byte(job.Output.Output), &out)
case "failed", "cancelled":
return nil, fmt.Errorf("job %s", job.Status)
}
time.Sleep(2 * time.Second) // never tight-loop
}
return nil, fmt.Errorf("timed out")
}
import java.security.MessageDigest;
static String idem(String task, String deviceOutput, int attempt) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(deviceOutput.getBytes("UTF-8"));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 8; i++) hex.append(String.format("%02x", d[i]));
return "switch-desk:" + task + ":" + hex + ":a" + attempt;
}
static String runAndWait(String token, String payload, String task, String deviceOutput)
throws Exception {
HttpRequest start = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idem(task, deviceOutput, 1))
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
String started = HTTP.send(start, HttpResponse.BodyHandlers.ofString()).body();
int i = started.indexOf("\"job_id\":\"") + 10;
String jobId = started.substring(i, started.indexOf('"', i));
long deadline = System.currentTimeMillis() + 180_000;
while (System.currentTimeMillis() < deadline) {
String job = apiGet(token, "/jobs/" + jobId);
if (job.contains("\"status\":\"succeeded\"")) return job;
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000); // never tight-loop
}
throw new RuntimeException("timed out");
}
require "digest"
def idem(payload, attempt = 1)
h = Digest::SHA256.hexdigest(payload["device_output"])[0, 16]
"switch-desk:#{payload['task']}:#{h}:a#{attempt}"
end
def run_and_wait(payload, token, timeout: 180)
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri,
"Authorization" => "Bearer #{token}",
"Content-Type" => "application/json",
"Idempotency-Key" => idem(payload))
req.body = payload.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body).dig("data", "job_id")
deadline = Time.now + timeout
while Time.now < deadline
job = api_get("/jobs/#{job_id}", token)
return JSON.parse(job.dig("output", "output")) if job["status"] == "succeeded"
raise job["status"] if %w[failed cancelled].include?(job["status"])
sleep 2 # never tight-loop
end
raise "timed out"
end
result = run_and_wait(payload, TOKEN)
result["findings"].each do |f|
puts "#{f['severity'].upcase} #{f['id']} #{f['target']} - #{f['title']}"
f["fix_lines"].each { |l| puts " #{l}" }
end
function idem(array $payload, int $attempt = 1): string {
$h = substr(hash("sha256", $payload["device_output"]), 0, 16);
return "switch-desk:{$payload['task']}:$h:a$attempt";
}
function run_and_wait(array $payload, string $token, int $timeout = 180): array {
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: " . idem($payload),
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
$deadline = time() + $timeout;
while (time() < $deadline) {
$job = get_json("/jobs/$jobId", $token);
if ($job["status"] === "succeeded") {
return json_decode($job["output"]["output"], true);
}
if (in_array($job["status"], ["failed", "cancelled"], true)) {
throw new RuntimeException($job["status"]);
}
sleep(2); // never tight-loop
}
throw new RuntimeException("timed out");
}
$result = run_and_wait($payload, $token);
foreach ($result["findings"] as $f) {
printf("%s %s %s - %s\n", strtoupper($f["severity"]), $f["id"], $f["target"], $f["title"]);
foreach ($f["fix_lines"] as $line) { echo " $line\n"; }
}
using System.Security.Cryptography;
using System.Text;
string Idem(string task, string deviceOutput, int attempt = 1)
{
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(deviceOutput)))
.ToLowerInvariant()[..16];
return $"switch-desk:{task}:{hash}:a{attempt}";
}
async Task<JsonElement> RunAndWaitAsync(string token, object payload, string task, string deviceOutput)
{
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run")
{
Content = JsonContent.Create(payload)
};
req.Headers.Add("Authorization", $"Bearer {token}");
req.Headers.Add("Idempotency-Key", Idem(task, deviceOutput));
var started = JsonDocument.Parse(await (await http.SendAsync(req)).Content.ReadAsStringAsync());
var jobId = started.RootElement.GetProperty("data").GetProperty("job_id").GetString();
var deadline = DateTime.UtcNow.AddMinutes(3);
while (DateTime.UtcNow < deadline)
{
var job = await ApiGetAsync(token, $"/jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded")
return JsonDocument.Parse(job.GetProperty("output").GetProperty("output").GetString()!)
.RootElement.Clone();
if (status is "failed" or "cancelled") throw new Exception(status);
await Task.Delay(2000); // never tight-loop
}
throw new TimeoutException();
}
Step 5 — or stream it with /run-stream
POST /run-stream is the same call over Server-Sent Events. Three event types:
job once with the job id, delta repeatedly with a text
fragment, and done once with status, charged_credits and
truncated. Concatenate every delta.text and parse the result as JSON.
Streaming is worth it because the section keys arrive in a known order, so you can drive a real
progress display off the stream rather than a character counter: "posture" then
"findings" then "acl_review" then "coverage_check" then
"change_plan" then "artifacts" then "summary" for the config
lane, and "posture", "interfaces", "root_causes",
"watch_list", "coverage_check", "artifacts",
"summary" for health. If the stream dies mid-flight, keep what parsed rather than
discarding a run you were billed for — appending "}]} and its near variants
recovers a surprising proportion of truncated replies.
curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-H "Idempotency-Key: switch-desk:health:$(printf %s "$DEVICE_OUTPUT" | shasum | cut -c1-16):a1" \
-d "$PAYLOAD"
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"{\"task\":\"health\","}
# event: delta data: {"text":"\"title\":\"..."}
# event: done data: {"status":"succeeded","charged_credits":2914,"truncated":false}
import urllib.request, json
def run_stream(payload, token, on_delta):
req = urllib.request.Request(
BASE + "/run-stream", data=json.dumps(payload).encode(),
headers={"Authorization": "Bearer " + token,
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Idempotency-Key": idem(payload)},
method="POST")
buf, event = "", ""
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode("utf-8").rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
data = json.loads(line[6:])
if event == "delta":
buf += data["text"]
on_delta(data["text"], buf)
elif event == "done":
return json.loads(buf), data
raise RuntimeError("stream ended without a done event")
result, done = run_stream(payload, TOKEN,
lambda chunk, so_far: print(len(so_far), "chars", end="\r"))
print(done["charged_credits"], "credits;", "truncated" if done["truncated"] else "complete")
async function runStream(payload, token, onDelta) {
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Idempotency-Key": idem(payload),
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let pending = "", buf = "", event = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += dec.decode(value, { stream: true });
let nl;
while ((nl = pending.indexOf("\n")) >= 0) {
const line = pending.slice(0, nl);
pending = pending.slice(nl + 1);
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (event === "delta") { buf += data.text; onDelta(data.text, buf); }
else if (event === "done") return { result: JSON.parse(buf), done: data };
}
}
}
throw new Error("stream ended without a done event");
}
const { result, done } = await runStream(payload, TOKEN,
(chunk, soFar) => process.stdout.write(`\r${soFar.length} chars`));
console.log(done.charged_credits, "credits");
func runStream(token string, payload map[string]any, onDelta func(string)) (string, error) {
b, _ := json.Marshal(payload)
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("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key",
idem(payload["task"].(string), payload["device_output"].(string), 1))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var buf strings.Builder
event := ""
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimSpace(line[7:])
case strings.HasPrefix(line, "data: "):
var d struct {
Text string `json:"text"`
Status string `json:"status"`
}
json.Unmarshal([]byte(line[6:]), &d)
if event == "delta" {
buf.WriteString(d.Text)
onDelta(d.Text)
} else if event == "done" {
return buf.String(), nil
}
}
}
return "", fmt.Errorf("stream ended without a done event")
}
static String runStream(String token, String payload, String task, String deviceOutput,
java.util.function.Consumer<String> onDelta) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Idempotency-Key", idem(task, deviceOutput, 1))
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
StringBuilder buf = new StringBuilder();
String[] event = { "" };
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) {
event[0] = line.substring(7).trim();
} else if (line.startsWith("data: ")) {
String data = line.substring(6);
if ("delta".equals(event[0])) {
int i = data.indexOf("\"text\":\"");
if (i >= 0) {
String text = data.substring(i + 8, data.lastIndexOf('"'));
buf.append(text);
onDelta.accept(text);
}
}
}
});
return buf.toString();
}
def run_stream(payload, token)
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri,
"Authorization" => "Bearer #{token}",
"Content-Type" => "application/json",
"Accept" => "text/event-stream",
"Idempotency-Key" => idem(payload))
req.body = payload.to_json
buf = +""
event = ""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ")
event = line[7..].strip
elsif line.start_with?("data: ")
data = JSON.parse(line[6..])
if event == "delta"
buf << data["text"]
yield data["text"], buf if block_given?
elsif event == "done"
return [JSON.parse(buf), data]
end
end
end
end
end
end
raise "stream ended without a done event"
end
function run_stream(array $payload, string $token, callable $onDelta): array {
$buf = "";
$event = "";
$result = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Accept: text/event-stream",
"Idempotency-Key: " . idem($payload),
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$event, &$result, $onDelta) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) {
$event = trim(substr($line, 7));
} elseif (str_starts_with($line, "data: ")) {
$data = json_decode(substr($line, 6), true);
if ($event === "delta") {
$buf .= $data["text"];
$onDelta($data["text"], $buf);
} elseif ($event === "done") {
$result = [json_decode($buf, true), $data];
}
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
if ($result === null) { throw new RuntimeException("stream ended without a done event"); }
return $result;
}
async Task<(JsonElement Result, JsonElement Done)> RunStreamAsync(
string token, object payload, string task, string deviceOutput, Action<string, int> onDelta)
{
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream")
{
Content = JsonContent.Create(payload)
};
req.Headers.Add("Authorization", $"Bearer {token}");
req.Headers.Add("Accept", "text/event-stream");
req.Headers.Add("Idempotency-Key", Idem(task, deviceOutput));
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
var evt = "";
while (await reader.ReadLineAsync() is string line)
{
if (line.StartsWith("event: ")) { evt = line[7..].Trim(); }
else if (line.StartsWith("data: "))
{
var data = JsonDocument.Parse(line[6..]).RootElement;
if (evt == "delta")
{
var text = data.GetProperty("text").GetString() ?? "";
buf.Append(text);
onDelta(text, buf.Length);
}
else if (evt == "done")
{
return (JsonDocument.Parse(buf.ToString()).RootElement.Clone(), data.Clone());
}
}
}
throw new Exception("stream ended without a done event");
}
The output contract
Both lanes return one JSON object with the same envelope. Every key is always present.
| field | type | notes |
|---|---|---|
task | string | The lane that was actually answered. Branch on this, not on what you sent. |
title | string | ≤ 80 chars, names the device and the headline. |
posture | string | Lane-specific enum, see the task table. |
confidence | string | high / medium / low — about the paste, not about the model. Both halves present and complete is high. |
verdict | string | One or two sentences fit to read out in a change meeting. |
exec_summary | string | 2–4 paragraphs of plain prose, blank-line separated, no bullet markup. |
assumptions, open_questions | string[] | What had to be assumed; what to ask the operator first. |
coverage_check | object[] | {id, status, note} with status in confirmed / set-aside / contradicted. One entry per prescan flag id. This is the accountability record — diff it against the ids you sent. |
artifacts | object[] | {name, language, content}. language is cisco, text or markdown. |
next_steps | string[] | Ordered, each doable by one person. |
summary | string | One paragraph that stands alone in a ticket. |
config adds
"findings": [
{
"id": "C-001",
"severity": "critical | high | medium | low",
"area": "security | correctness | resilience | hygiene | compliance",
"title": "the defect, not the remedy",
"target": "GigabitEthernet1/0/24 | USER-OUT | line vty 0 4 | global",
"evidence": "the exact config lines or prescan measurements that establish it",
"impact": "what an attacker or an outage does with it, on THIS device",
"fix_lines": ["interface GigabitEthernet1/0/24", " no duplex half", " duplex auto", " speed auto"],
"rollback_lines": ["interface GigabitEthernet1/0/24", " speed 100", " duplex half"],
"risk_of_fix": "none | low | medium | high",
"window": "any | maintenance | emergency",
"refs": ["DUPLEX-HALF-CONFIGURED", "LATE-COLLISIONS"],
"confidence": "high | medium | low"
}
],
"acl_review": [ { "name": "USER-OUT", "verdict": "sound | redundant | ineffective | dangerous | undefined", "note": "..." } ],
"change_plan": [ { "order": 1, "what": "...", "verify_command": "show interface Gi1/0/24 | include duplex",
"rollback": "...", "blast_radius": "one interface | one vlan | management plane | whole device" } ]
Three artifacts, in order: remediation.cfg (cisco) with every fix block in
change-plan order, rollback.cfg (cisco) with the matching rollbacks in
reverse, and verify.txt (text) with one read-only command per line, each
preceded by a ! comment naming what it proves.
fix_lines is deliberately an array of single commands rather than one blob: it is what
lets you feed the block straight into a config-push library, and it is what lets the page check
each line before it shows it to you.
health adds
"interfaces": [
{
"name": "GigabitEthernet1/0/24",
"grade": "healthy | watch | degraded | failing | down | admin-down",
"layer": "physical | datalink | config | capacity | control-plane | unknown",
"cause": "the mechanism, not the symptom",
"evidence": "the counters, with the rate the prescan computed",
"counters_cited": ["crc", "late_collision", "output_drops"],
"remedy": "the next physical or configuration action, specific to this port",
"confidence": "high | medium | low"
}
],
"root_causes": [ { "id": "R-001", "cause": "...", "interfaces": ["GigabitEthernet1/0/24"],
"mechanism": "why these counters move together, in causal order",
"test": "test cable-diagnostics tdr interface Gi1/0/24",
"confidence": "high | medium | low" } ],
"watch_list": [ { "name": "GigabitEthernet1/0/23", "metric": "output drops per million",
"threshold": "10", "why": "..." } ]
Two artifacts, in order: triage.md (markdown) and
verify.txt (text).
What the page checks that you should check too
If you are consuming this in a script, reproduce these four checks — they are cheap, they are deterministic, and they are where a plausible-looking answer turns out to be wrong:
- Every prescan flag id you sent appears in
coverage_check. A missingcriticalid means the answer skipped the worst thing you told it about. - Every
targetand everyinterfaces[].nameexists in your paste. Normalise the abbreviation first —Gi1/0/24andGigabitEthernet1/0/24are the same port. - Every
verify_commandand everyroot_causes[].testis read-only. Anything that is not ashow, aping, atracerouteor atest cable-diagnosticsdoes not belong in a verification step. - No generated block contains a destructive command —
write erase,erase startup-config,reload,format flash:— and noshutdownlands on an interface your own inventory calls an uplink or a management port.
Rate limits and cost
/estimate,/meand/guestare free./runand/run-streamare metered against the calling user's balance, at the model's token rates plus the publisher's 10% markup.- A 429 means back off. Space out a batch and never tight-loop a job poll; two seconds between polls is plenty.
- If you are walking an archive, call
/estimateonce on a representative device to size the job before you run a thousand of them.
Limits worth knowing before you build on it
- It sees only
device_output. No topology, no neighbour output, no logs, no traffic. A claim about the far end of a link arrives as a hypothesis with a test attached, and should be consumed that way. - It generates configuration; nothing applies it. If you wire this into a push pipeline, gate on
windowandrisk_of_fixand keep a human in the loop for anything whoseblast_radiusismanagement planeorwhole device. - Tuned for Cisco IOS and IOS-XE. NX-OS, IOS-XR and Arista EOS partly parse — the counter reader and the subnet arithmetic travel, the config keywords do not.
- The browser sends at most 22,000 characters of device output, cut on whole blocks by priority. Over the API you set your own size; a 48-port switch config plus full counters is comfortably inside a normal request.
Sources
Derived from two agent skills:
@affaan-m/cisco-ios-patterns
drives the config lane, and
@affaan-m/network-interface-health
drives the health lane. This is a derived work, not affiliated with those skills'
author and not affiliated with or endorsed by Cisco Systems.