← PowerShell Clinic
API
Tokens

Run PowerShell Clinic from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page scans the script for free - cmdlet calls not in parentheses next to -and / -or, $null on the right of a comparison, ConvertTo-Json without -Depth, no strict mode, a try that non-terminating errors skip, native commands with no $LASTEXITCODE check, paths built by concatenation, and on the security side credentials in the script, SQL built by interpolation, policy and certificate checks switched off and secrets written to output - each with its rule id, line and fix, in pwscan.js. The run reviews and fixes the script, or hardens it; the page then holds the reply to its own scan and re-scans the script that comes back, checking that no parameter or function was lost and that nothing PowerShell 7-only landed in a 5.1 script (pwreply.js). Not affiliated with Microsoft.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"pwsh-clinic"}), so no slug header is needed afterwards. Send it as Authorization: Bearer .... The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a field the run needs (task, code, shell, facts) is missing or not a string. Send every field as a string; focus may be "".
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes - "review" (Windows PowerShell pitfalls, the PW- rules: a verdict, findings with fixes, a call on every scan finding, and the whole script rewritten) and "harden" (security, the PS- rules: the same parts, plus the secrets the hardened script reads and how to run it safely). A missing or unknown task is answered as review, with a caveat saying so. Both lanes send the same fields.

The input

Every field is a string.

FieldRequiredWhat
taskyes"review" or "harden". The model answers that task and only that task.
codeyesthe script, the whole .ps1 file. The page clips a long file (over 24,000 characters) by keeping the head and the tail and putting a # ... N lines (a-b) omitted by PowerShell Clinic to fit the run ... line in the middle; the model keeps that line where it was and will not guess what it hid.
shellyeswhere the script runs: "5.1" (Windows PowerShell 5.1), "7" (PowerShell 7) or "both". A script that must run on 5.1 gets nothing PowerShell 7-only (??, ?., the ternary, -Parallel, ConvertFrom-Json -AsHashtable).
factsyesthe page's own scan of the same code: the shell, a prescan verdict, counts per lane, the interface to keep (parameters and functions), and every finding as rule | severity | line | text. See the facts format.
focusnowhat the administrator wants weighed, for example "Runs as SYSTEM from Task Scheduler; no extra modules allowed." May be empty; when given, the summary says how it changed the answer.
retry_notenopresent only on a reformat retry: the previous reply could not be read as one JSON object, and this says how to answer again. Leave it out otherwise.

The facts format

facts is the page's scan, written as plain text. The page builds it from the script with pwscan.js. A client calling the API should send its own findings list in the same format (from its own linter, or from pwscan.js run under node - see Build the input yourself), or at least a short summary of the script: the shell, the parameters and functions to keep, and what you already know is wrong. The scan's format gets the most out of a run: the model answers every error and warn rule id of its lane's family it finds there (one prescan entry per id) and keeps every parameter and function named on the Interface to keep line. The format, line by line:

Runs on: <Windows PowerShell 5.1|PowerShell 7|both 5.1 and 7>.
Prescan verdict: <ship|refactor|rework> (<n> errors, <n> warnings, <n> notes).
Review findings (PW-): <n> errors, <n> warnings. Security findings (PS-): <n> errors, <n> warnings.
Interface to keep: parameters <$Name, ...|(none)>; functions <Name, ...|(none)>.
Clipped: <only when the code was clipped>
Findings (rule | severity | line | text):
- <rule id> | <error|warn|info> | line <n> | <title>< (note)> | <the source line>

A script with no findings gets the single line - none. Both lanes receive the same facts: PW- findings belong to review, PS- findings to harden, and each lane answers its own family. The prescan verdict is rework at two errors or more, or when errors and warnings land in four of the nine areas (syntax, null handling, error handling, JSON and data, paths, output and encoding, secrets, injection, policy and trust); refactor on any error or warning; ship otherwise. A real example - the facts the page sends for the short copy script used in the worked example below:

Runs on: Windows PowerShell 5.1.
Prescan verdict: rework (1 error, 3 warnings, 2 notes).
Review findings (PW-): 1 error, 3 warnings. Security findings (PS-): 0 errors, 0 warnings.
Interface to keep: parameters $Source, $Target; functions (none).
Findings (rule | severity | line | text):
- PW-OPPAREN | error | line 7 | Cmdlet call not in parentheses next to -and / -or | if (Test-Path $Source -and Test-Path $Target) {
- PW-STRICT | warn | line 1 | No Set-StrictMode | param(
- PW-NULLRIGHT | warn | line 10 | $null on the right of a comparison | if ($items -eq $null) { exit 0 }
- PW-JSONDEPTH | warn | line 11 | ConvertTo-Json without -Depth | $items | Select-Object Name, Length | ConvertTo-Json | Out-File (Join-Path $Target manifest.json)
- PS-NOVALIDATE | info | line 1 | Parameters without validation | param(
- PW-OUTENC | info | line 11 | File written without -Encoding | $items | Select-Object Name, Length | ConvertTo-Json | Out-File (Join-Path $Target manifest.json)

Rule ids the scan uses, with their severity and lane:

RuleSeverityLaneTitle
PW-OPPARENerrorreviewCmdlet call not in parentheses next to -and / -or
PW-NULLRIGHTwarnreview$null on the right of a comparison
PW-COUNTinforeview.Count on a value that may be null or single
PW-JSONDEPTHwarnreviewConvertTo-Json without -Depth
PW-STRICTwarnreviewNo Set-StrictMode
PW-EAPwarnreviewtry/catch that non-terminating errors skip
PW-LASTEXITwarnreviewNative command without a $LASTEXITCODE check
PW-PATHCONCATinforeviewPath built by string concatenation
PW-RELPATHinforeviewPath relative to the current directory
PW-ENCODINGinforeviewNon-ASCII symbols in output
PW-OUTENCinforeviewFile written without -Encoding
PS-PLAINCREDerrorhardenHardcoded credential
PS-SQLINTERPerrorhardenSQL built by interpolation
PS-IEXwarnhardenInvoke-Expression
PS-BYPASSwarnhardenExecution policy bypassed
PS-CERTSKIPwarnhardenCertificate validation switched off
PS-SECRETOUTwarnhardenSecret written to output
PS-NOVALIDATEinfohardenParameters without validation

A finding the scan cannot see (a logic bug, a race, a destructive command without -WhatIf support, a missing exit code) comes back with the rule NEW.

The standard the model applies

The two source skills - @sickn33/powershell-windows (the review lane) and @josiahsiegel/powershell-security (the harden lane) - are the only standard:

House rules

The replies

Both lanes return the whole script as code in a rewrite object, with name (such as backup.ps1) and code. verdict is ship, refactor or rework in both; a scan error of the lane's family the model agrees with rules out ship.

review

ship (correct and robust for its shell), refactor (works, with pitfalls) or rework (fails silently, misparses, or reports success after a failure).

  1. summary - two or three sentences: what the script does, the verdict and the first thing to fix.
  2. findings - most severe first: rule (a PW- rule id from the facts, or NEW), severity (high, medium or low), title, where (such as line 7), why (one or two sentences about this script), fix (the corrected lines).
  3. prescan - one entry per PW- error and warn rule id in the facts: rule, call (agree or disagree), note (why, one sentence).
  4. rewrite - name and code: the whole fixed script, including what did not change.
  5. kept - anything deliberately left as it was: rule, what, why.
  6. caveats - what could not be seen or verified.
{
  "task": "review",
  "verdict": "refactor",
  "summary": "",
  "findings": [ { "rule": "PW-OPPAREN", "severity": "high", "title": "", "where": "line 10", "why": "", "fix": "" } ],
  "prescan": [ { "rule": "PW-OPPAREN", "call": "agree", "note": "" } ],
  "rewrite": { "name": "backup.ps1", "code": "" },
  "kept": [ { "rule": "", "what": "", "why": "" } ],
  "caveats": [ "" ]
}

harden

ship (nothing secret, injectable or bypassed), refactor (one weakness) or rework (a credential in the script, an injectable query or command, or checks switched off). The same summary, findings (PS- rule ids or NEW), prescan (one entry per PS- error and warn rule id), rewrite (the whole hardened script), kept and caveats, plus:

  1. secrets - every secret the hardened script reads, as [{"name", "for"}]: name as passed to Get-Secret, for what it is. Empty when there are none.
  2. run - one or two sentences on how to run it safely: signing, execution policy, the account it needs.
{
  "task": "harden",
  "verdict": "rework",
  "summary": "",
  "findings": [ { "rule": "PS-PLAINCRED", "severity": "high", "title": "", "where": "line 9", "why": "", "fix": "" } ],
  "prescan": [ { "rule": "PS-PLAINCRED", "call": "agree", "note": "" } ],
  "rewrite": { "name": "provision.ps1", "code": "" },
  "secrets": [ { "name": "", "for": "" } ],
  "run": "",
  "kept": [ { "rule": "", "what": "", "why": "" } ],
  "caveats": [ "" ]
}

The page holds either reply to its own scan: every error and warning of the lane's family answered by rule id (in findings or prescan), no rule id the scan did not report other than NEW, no ship over an undisputed error, and no claim that the script was run or tested. Then it re-scans the rewrite: a scan error or warning of the lane still present must be listed in kept, no new problem may appear, every parameter and function must still be declared, a 5.1 script may not use PowerShell 7-only syntax, and a clipped input must keep its omitted-lines comment. A harden rewrite that reads a secret with Get-Secret must list it under secrets. Each miss is shown next to the reply as a note.

Reading the reply

The job's output.output is text that should be exactly one JSON object. Parse it; if a model ever wraps it in prose or a fence, take the text from the first { to the last } and parse that (the page does the same in pwreply.js). If it still does not parse, send the same input again with a retry_note such as "Your previous reply could not be read. Answer with ONE JSON object and nothing else." and a new attempt number in the key (see step 5). In "harden", read secrets before you run the rewrite: create each listed secret in your vault with Set-Secret -Name <name> (Microsoft.PowerShell.SecretManagement with a registered vault), then follow run, and try the script with -WhatIf first where it supports it.

Worked example - one script, both lanes

A short copy script for Windows PowerShell 5.1 with review-lane pitfalls only: a Test-Path call on each side of -and without parentheses (so -and is read as a parameter of the first Test-Path), $null on the right, ConvertTo-Json without -Depth, no strict mode and Out-File without -Encoding - prescan rework. The input below is exactly what the page sends for the review lane; for the harden lane only task changes to "harden", and the same facts go along (that lane answers the PS- findings - here only the informational PS-NOVALIDATE). Input:

{
 "task": "review",
 "code": "param(\n    [string]$Source,\n    [string]$Target\n)\n\n$items = Get-ChildItem $Source -Recurse\nif (Test-Path $Source -and Test-Path $Target) {\n    Copy-Item $Source $Target -Recurse\n}\nif ($items -eq $null) { exit 0 }\n$items | Select-Object Name, Length | ConvertTo-Json | Out-File (Join-Path $Target manifest.json)",
 "shell": "5.1",
 "facts": "Runs on: Windows PowerShell 5.1.\nPrescan verdict: rework (1 error, 3 warnings, 2 notes).\nReview findings (PW-): 1 error, 3 warnings. Security findings (PS-): 0 errors, 0 warnings.\nInterface to keep: parameters $Source, $Target; functions (none).\nFindings (rule | severity | line | text):\n- PW-OPPAREN | error | line 7 | Cmdlet call not in parentheses next to -and / -or | if (Test-Path $Source -and Test-Path $Target) {\n- PW-STRICT | warn | line 1 | No Set-StrictMode | param(\n- PW-NULLRIGHT | warn | line 10 | $null on the right of a comparison | if ($items -eq $null) { exit 0 }\n- PW-JSONDEPTH | warn | line 11 | ConvertTo-Json without -Depth | $items | Select-Object Name, Length | ConvertTo-Json | Out-File (Join-Path $Target manifest.json)\n- PS-NOVALIDATE | info | line 1 | Parameters without validation | param(\n- PW-OUTENC | info | line 11 | File written without -Encoding | $items | Select-Object Name, Length | ConvertTo-Json | Out-File (Join-Path $Target manifest.json)",
 "focus": ""
}

Reply (an illustrative sketch of the shape, abbreviated - long strings end in ...; a real reply has every field in full):

{
 "task": "review",
 "verdict": "rework",
 "summary": "The script copies Source to Target and writes a JSON manifest of the copied items. Verdict rework: Test-Path $Source -and Test-Path $Target is parsed as one Test-Path call with -and as an argument, so the copy guard does not test what it reads as. Fix that first with ((Test-Path $Source) -and (Test-Path $Target)) ...",
 "findings": [
  {
   "rule": "PW-OPPAREN",
   "severity": "high",
   "title": "Cmdlet calls next to -and without parentheses",
   "where": "line 7",
   "why": "Without parentheses the second Test-Path and -and become arguments of the first call ...",
   "fix": "if ((Test-Path $Source) -and (Test-Path $Target)) {"
  },
  {
   "rule": "PW-NULLRIGHT",
   "severity": "medium",
   "title": "$null on the right of -eq",
   "where": "line 10",
   "why": "With an array on the left, -eq filters the array instead of testing it ...",
   "fix": "if ($null -eq $items) { exit 0 }"
  },
  {
   "rule": "PW-JSONDEPTH",
   "severity": "medium",
   "title": "ConvertTo-Json without -Depth",
   "where": "line 11",
   "why": "The default depth of 2 flattens nested values to type names ...",
   "fix": "ConvertTo-Json -Depth 5 | Out-File (Join-Path $Target manifest.json) -Encoding UTF8"
  },
  {
   "rule": "PW-STRICT",
   "severity": "low",
   "title": "No strict mode",
   "where": "line 1",
   "why": "A typo in a variable name reads as $null instead of failing ...",
   "fix": "Set-StrictMode -Version Latest"
  }
 ],
 "prescan": [
  {
   "rule": "PW-OPPAREN",
   "call": "agree",
   "note": "Line 7 passes -and and the second Test-Path to the first call ..."
  },
  {
   "rule": "PW-STRICT",
   "call": "agree",
   "note": "Nothing turns on strict mode ..."
  },
  {
   "rule": "PW-NULLRIGHT",
   "call": "agree",
   "note": "Line 10 compares with $null on the right ..."
  },
  {
   "rule": "PW-JSONDEPTH",
   "call": "agree",
   "note": "Line 11 serializes without -Depth ..."
  }
 ],
 "rewrite": {
  "name": "copy.ps1",
  "code": "param(\n    [string]$Source,\n    [string]$Target\n)\nSet-StrictMode -Version Latest\n$ErrorActionPreference = 'Stop'\n\n$items = Get-ChildItem $Source -Recurse\nif ((Test-Path $Source) -and (Test-Path $Target)) {\n..."
 },
 "kept": [
  {
   "rule": "PS-NOVALIDATE",
   "what": "The parameters have no validation attributes.",
   "why": "A harden-lane note; the harden pass is the place for it ..."
  }
 ],
 "caveats": [
  "Nothing was run; try it with -WhatIf on Copy-Item first ..."
 ]
}

Sent to "harden", the same script has no credential, query or bypass in it, so a fitting answer is ship or refactor with "secrets": [], perhaps a [ValidateNotNullOrEmpty()] on $Source and $Target for PS-NOVALIDATE, and a run line about signing the script and the account it needs.

Build the input yourself

The scanner and the reply checker are plain scripts that run under node (global.window = global; require("./pwscan.js"); require("./pwreply.js")), then:

const S = PwScan, R = PwReply;
const st = { code: fs.readFileSync("copy.ps1", "utf8"), shell: "5.1", focus: "" };
const scan = S.scan(st);                        // findings, counts, byLane, verdict, shape (params, functions)
const b = S.buildInput(st, "review");           // or "harden"; clips long files and writes the facts
const wire = S.toWire(b.input);                 // every field a string - POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b)                            // {notes, warn}: anything the reply missed or got wrong

Get a token for the steps below from /tokens.html.

Step 1 - a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://pwsh-clinic.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 - a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"pwsh-clinic"}' | jq -r '.data.token')

Step 3 - GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}'

Step 4 - POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save the worked-example input above as input.json (with task set to "review" or "harden") to follow along.

# input.json: the run input object - {"task": "review", "code": "param( ...", "shell": "5.1", "facts": "Runs on: Windows PowerShell 5.1. ...", "focus": ""}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 - POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses pwsh-clinic:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a review and a harden pass of the same script are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. Both lanes carry verdict, summary, findings, prescan, rewrite, kept and caveats; a harden pass also carries secrets and run. The code below parses the JSON object out of the output and prints the verdict, the summary and each finding's severity and title; for "harden" it also prints a Set-Secret line for each listed secret - create those in your vault before you run the rewrite - and the run advice.

KEY="pwsh-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '"verdict: \(.verdict)", .summary, (.findings[] | "[\(.severity)] \(.title)"),
  (if .task == "harden" then (.secrets[]? | "Set-Secret -Name \(.name)   # \(.for)"), "run: \(.run)" else empty end)' reply.json

Step 6 - POST /run-stream

The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.

# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: pwsh-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json