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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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 "". |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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.
| Field | Required | What |
|---|---|---|
task | yes | "review" or "harden". The model answers that task and only that task. |
code | yes | the 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. |
shell | yes | where 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). |
facts | yes | the 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. |
focus | no | what 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_note | no | present 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:
| Rule | Severity | Lane | Title |
|---|---|---|---|
PW-OPPAREN | error | review | Cmdlet call not in parentheses next to -and / -or |
PW-NULLRIGHT | warn | review | $null on the right of a comparison |
PW-COUNT | info | review | .Count on a value that may be null or single |
PW-JSONDEPTH | warn | review | ConvertTo-Json without -Depth |
PW-STRICT | warn | review | No Set-StrictMode |
PW-EAP | warn | review | try/catch that non-terminating errors skip |
PW-LASTEXIT | warn | review | Native command without a $LASTEXITCODE check |
PW-PATHCONCAT | info | review | Path built by string concatenation |
PW-RELPATH | info | review | Path relative to the current directory |
PW-ENCODING | info | review | Non-ASCII symbols in output |
PW-OUTENC | info | review | File written without -Encoding |
PS-PLAINCRED | error | harden | Hardcoded credential |
PS-SQLINTERP | error | harden | SQL built by interpolation |
PS-IEX | warn | harden | Invoke-Expression |
PS-BYPASS | warn | harden | Execution policy bypassed |
PS-CERTSKIP | warn | harden | Certificate validation switched off |
PS-SECRETOUT | warn | harden | Secret written to output |
PS-NOVALIDATE | info | harden | Parameters 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:
- Operators: every cmdlet call next to
-and/-orgoes in its own parentheses -if ((Test-Path $a) -and (Test-Path $b)); without them the operator is parsed as a parameter of the first cmdlet. - Nulls:
$nullon the left ($null -eq $x); count with@($items).Countafter a null check; guard property access on values that may be$null. - Errors:
Set-StrictMode -Version Latestand$ErrorActionPreference = 'Stop'at the top (or-ErrorAction Stopon the cmdlets insidetry); after every native executable check$LASTEXITCODE;exit 0/exit 1so schedulers see the outcome. - Data:
ConvertTo-Json -Depth Nfor anything nested; read JSON withGet-Content -Raw | ConvertFrom-Json; write files with an explicit-Encoding(Windows PowerShell 5.1 defaultsOut-Fileto UTF-16). - Paths and output:
Join-Pathinstead of string concatenation; resolve files against$PSScriptRoot, not the current directory; portable log markers such as[OK]and[WARN]. - Secrets (harden): never a credential in the script -
Get-Secretfrom Microsoft.PowerShell.SecretManagement,Get-Credentialfor interactive runs, a managed identity or service principal for automation; never write a secret to the host, the pipeline or a log. - Injection and input (harden): parameterized queries; commands called directly rather than built as text; validation attributes (
[ValidateSet],[ValidatePattern],[ValidateNotNullOrEmpty]) on parameters that reach a path, a query or a command;#Requiresfor modules and versions. - Policy and trust (harden): signed scripts under
RemoteSigned/AllSigned; certificate validation left on; least privilege, with JEA endpoints for delegated admin tasks.
House rules
- Every statement is grounded in the pasted script; no server, vault name, module or behaviour is invented. What the answer depends on but could not see goes in
caveats, and a secret name the model introduces is named so the administrator can create it. - Every
errorandwarnfinding of the lane's family in the facts is addressed by its rule id; a finding the model thinks is wrong is disputed with a reason, never silently dropped. - The script's interface stays: every parameter and function named in the facts. Behaviour stays the same apart from the fixes.
- The
shellis respected: nothing PowerShell 7-only in a script that must run on 5.1. - No claim that anything was run or tested - "run it with
-WhatIf" or "run Invoke-ScriptAnalyzer", not "this works". - Plain text in strings; code goes in the code fields as plain PowerShell with
\nnewlines and no Markdown fences. The reply is ONE JSON object and nothing else.
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).
summary- two or three sentences: what the script does, the verdict and the first thing to fix.findings- most severe first:rule(aPW-rule id from the facts, orNEW),severity(high,mediumorlow),title,where(such asline 7),why(one or two sentences about this script),fix(the corrected lines).prescan- one entry perPW-errorandwarnrule id in the facts:rule,call(agreeordisagree),note(why, one sentence).rewrite-nameandcode: the whole fixed script, including what did not change.kept- anything deliberately left as it was:rule,what,why.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:
secrets- every secret the hardened script reads, as[{"name", "for"}]:nameas passed toGet-Secret,forwhat it is. Empty when there are none.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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://pwsh-clinic.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// pwsh-clinic.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://pwsh-clinic.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://pwsh-clinic.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// PwshClinic.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class PwshClinic {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://pwsh-clinic.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = +"YOUR_TOKEN" # from https://pwsh-clinic.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://pwsh-clinic.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://pwsh-clinic.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "pwsh-clinic"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "pwsh-clinic" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "pwsh-clinic"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "pwsh-clinic"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "pwsh-clinic" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "pwsh-clinic"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "pwsh-clinic" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"task": "review", "code": "param( ...", "shell": "5.1", "facts": "Runs on: Windows PowerShell 5.1. ...", "focus": ""}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "review" or "harden", code, shell, facts, focus} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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
import hashlib, time
key = "pwsh-clinic:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
out = j["output"]["output"]
reply = json.loads(out[out.index("{"):out.rindex("}") + 1]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"))
print("verdict:", reply.get("verdict"))
print(reply.get("summary"))
for f in reply.get("findings", []):
print(f"[{f['severity']}] {f['title']}")
if reply.get("task") == "harden":
for s in reply.get("secrets", []):
print(f"Set-Secret -Name {s['name']} # {s['for']}") # create these before running the rewrite
print("run:", reply.get("run"))
import { createHash } from "node:crypto";
const key = `pwsh-clinic:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const out = j.output.output;
const reply = JSON.parse(out.slice(out.indexOf("{"), out.lastIndexOf("}") + 1));
console.log(j.charged_credits, j.truncated);
console.log("verdict:", reply.verdict);
console.log(reply.summary);
for (const f of reply.findings || []) console.log(`[${f.severity}] ${f.title}`);
if (reply.task === "harden") {
for (const s of reply.secrets || []) console.log(`Set-Secret -Name ${s.name} # ${s.for}`); // create these first
console.log("run:", reply.run);
}
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "strings", "time" to the imports
key := "pwsh-clinic:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
out := j["output"].(map[string]any)["output"].(string)
var reply map[string]any
json.Unmarshal([]byte(out[strings.Index(out, "{"):strings.LastIndex(out, "}")+1]), &reply)
fmt.Println(j["charged_credits"], j["truncated"])
fmt.Println("verdict:", reply["verdict"])
fmt.Println(reply["summary"])
findings, _ := reply["findings"].([]any)
for _, x := range findings {
f := x.(map[string]any)
fmt.Printf("[%v] %v\n", f["severity"], f["title"])
}
if reply["task"] == "harden" {
secrets, _ := reply["secrets"].([]any)
for _, x := range secrets {
s := x.(map[string]any)
fmt.Printf("Set-Secret -Name %v # %v\n", s["name"], s["for"])
}
fmt.Println("run:", reply["run"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "pwsh-clinic:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
String out = j.get("output").get("output").asText();
JsonNode reply = M.readTree(out.substring(out.indexOf('{'), out.lastIndexOf('}') + 1));
System.out.println(j.get("charged_credits") + " " + j.get("truncated"));
System.out.println("verdict: " + reply.path("verdict").asText());
System.out.println(reply.path("summary").asText());
for (JsonNode f : reply.path("findings")) System.out.println("[" + f.path("severity").asText() + "] " + f.path("title").asText());
if ("harden".equals(reply.path("task").asText())) {
for (JsonNode s : reply.path("secrets")) System.out.println("Set-Secret -Name " + s.path("name").asText() + " # " + s.path("for").asText());
System.out.println("run: " + reply.path("run").asText());
}
require "digest"
key = "pwsh-clinic:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
out = j["output"]["output"]
reply = JSON.parse(out[out.index("{")..out.rindex("}")])
puts [j["charged_credits"], j["truncated"]].inspect
puts "verdict: #{reply["verdict"]}", reply["summary"]
(reply["findings"] || []).each { |f| puts "[#{f["severity"]}] #{f["title"]}" }
if reply["task"] == "harden"
(reply["secrets"] || []).each { |s| puts "Set-Secret -Name #{s["name"]} # #{s["for"]}" }
puts "run: #{reply["run"]}"
end
$key = "pwsh-clinic:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$out = $j["output"]["output"];
$from = strpos($out, "{");
$reply = json_decode(substr($out, $from, strrpos($out, "}") - $from + 1), true);
echo $j["charged_credits"], "\n";
echo "verdict: ", $reply["verdict"] ?? "", "\n", $reply["summary"] ?? "", "\n";
foreach ($reply["findings"] ?? [] as $f) echo "[", $f["severity"], "] ", $f["title"], "\n";
if (($reply["task"] ?? "") === "harden") {
foreach ($reply["secrets"] ?? [] as $s) echo "Set-Secret -Name ", $s["name"], " # ", $s["for"], "\n";
echo "run: ", $reply["run"] ?? "", "\n";
}
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"pwsh-clinic:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var output = j["output"]!["output"]!.GetValue<string>();
var from = output.IndexOf('{');
var reply = JsonNode.Parse(output.Substring(from, output.LastIndexOf('}') - from + 1))!;
Console.WriteLine($"{j["charged_credits"]} {j["truncated"]}");
Console.WriteLine($"verdict: {reply["verdict"]}");
Console.WriteLine(reply["summary"]);
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"[{f!["severity"]}] {f["title"]}");
if ((string?)reply["task"] == "harden") {
foreach (var s in reply["secrets"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"Set-Secret -Name {s!["name"]} # {s["for"]}");
Console.WriteLine($"run: {reply["run"]}");
}
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
rev = json.load(open("input.json")) # the same run input as input.json (see the input table)
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "pwsh-clinic:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `pwsh-clinic:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "pwsh-clinic:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<24) // the final message carries the whole reply
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "pwsh-clinic:" + M.readTree(rev).path("task").asText() + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "pwsh-clinic:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, 443, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$task = json_decode($rev, true)["task"];
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: pwsh-clinic:$task:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"pwsh-clinic:{JsonNode.Parse(rev)!["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);