← Switch Desk / API
Tokens

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

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate for the lane you are about to run, then top up.
forbidden403The 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_found404Unknown job id, or the app slug in the guest mint does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The 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_limited429Too many requests. Back off and retry; do not tight-loop a poll.
internal5xxA 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.

taskthe question it answersthe body it addsposture 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

fieldtyperequiredwhat it is
taskstringyes"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_outputstringyesThe 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.
hostnamestringnoTaken from the config when present. Sent separately so a paste that is only counters still names the device.
rolestringnoOne 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.
goalstringnoOne of harden, troubleshoot, handover, change, baseline. Changes the ordering, not the content.
contextstringnoFree 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.
prescanobjectno, strongly recommendedThe deterministic facts. See below.
omittedobjectno{interfaces[], counters[], note} — blocks whose raw lines were dropped to fit the budget but whose facts are still in prescan.
handoffobjectno{from, posture, interfaces[], notes[]} from the other lane's answer.
retry_notestringnoUsed 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",...}}

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}}

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}}

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

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}

The output contract

Both lanes return one JSON object with the same envelope. Every key is always present.

fieldtypenotes
taskstringThe lane that was actually answered. Branch on this, not on what you sent.
titlestring≤ 80 chars, names the device and the headline.
posturestringLane-specific enum, see the task table.
confidencestringhigh / medium / low — about the paste, not about the model. Both halves present and complete is high.
verdictstringOne or two sentences fit to read out in a change meeting.
exec_summarystring2–4 paragraphs of plain prose, blank-line separated, no bullet markup.
assumptions, open_questionsstring[]What had to be assumed; what to ask the operator first.
coverage_checkobject[]{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.
artifactsobject[]{name, language, content}. language is cisco, text or markdown.
next_stepsstring[]Ordered, each doable by one person.
summarystringOne 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:

  1. Every prescan flag id you sent appears in coverage_check. A missing critical id means the answer skipped the worst thing you told it about.
  2. Every target and every interfaces[].name exists in your paste. Normalise the abbreviation first — Gi1/0/24 and GigabitEthernet1/0/24 are the same port.
  3. Every verify_command and every root_causes[].test is read-only. Anything that is not a show, a ping, a traceroute or a test cable-diagnostics does not belong in a verification step.
  4. No generated block contains a destructive commandwrite erase, erase startup-config, reload, format flash:and no shutdown lands on an interface your own inventory calls an uplink or a management port.

Rate limits and cost

Limits worth knowing before you build on it

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.