← Set Diff Desk / API
Tokens

Drive Set Diff Desk from your own code

The page does its free work in the browser: it reads the output of terraform show -json, matches the elements of every AzureRM Set-type attribute by its key, sets aside the elements that only moved (order-only entries, N01..) and numbers every real change (findings, F01..): replacements and the attributes that force them, creates, deletes, Set elements added, removed or modified, other attributes, and suspected reorders in lists the skill's table does not know. Its paid review lane is available over HTTP. You send one JSON object with task set to "review", and the model replies with one JSON object. The easiest way to build the facts string is the page itself: SetKit.buildInput(SetKit.analyze(planText, {ignoreCase}), {environment, intent}) in setkit.js, which also runs under Node. It never puts the plan itself, its prior state, variables or configuration into the input, and every value Terraform marked sensitive becomes (sensitive).

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. The review runs on gpt-terra with a 10% publisher markup. A run needs a signed-in (user) token and is metered: the platform holds the estimate's hold_credits while the run is in flight and then charges only what it used (charged_credits). /estimate is free. A plain-text summary is at /llms.txt.

The response envelope

Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.

Error codes

HTTPcodemeaning
400VALIDATION_ERRORThe body is not a JSON object, or a field is the wrong type. Every field of the run input is a string.
401UNAUTHORIZEDNo token, or an expired one: get a new one (step 2).
402INSUFFICIENT_CREDITSThe balance is below the run's minimum. Top up at skillsafe.ai/account/billing/.
403FORBIDDENA guest token tried a metered run: sign in for a personal token.
404NOT_FOUNDUnknown job id.
429RATE_LIMITEDBack off and retry with the same Idempotency-Key.

The task field

One lane: "review". The model answers every finding (confirmed or disputed, with a risk of high, medium, low or none, what it does and why), confirms or disputes the order-only entries, writes one to six checks before or after apply, proposes Set-table additions for the suspected reorders it confirms, compares the changes with your intent, reports text in the plan addressed to reviewers, and writes a pull-request comment. Its verdict follows from the risks: any high gives hold, any medium or low apply with checks, otherwise apply.

Input fields (all strings)

fieldwhencontent
taskrequiredreview
environmentrequiredproduction, staging, development or unspecified. In production a replace or delete is always high.
intentoptionalOne line: what the change is meant to do (up to 400 characters).
factsrequiredA JSON string: plan{terraform_version, format_version, resources_total, changed, azurerm_changed, by_action}, counts, exit_code (the skill's: 2 a replacement, 1 a Set change or create/delete, 0 none), ignore_case, findings[{id, kind, severity, address, type, path, text, detail?, forced_by?}] (up to 80, every high one kept), noise[{id, address, path, count}] (up to 120), unknown[], injection[{address, path, text}], warnings[], table_types[], notes[].
retry_noteoptionalOnly on a reformat retry: what was wrong with the previous reply.

The reply contract

task, task_note, verdict (apply | apply with checks | hold), headline, intent{status: matches|partly|does not match|no intent given, note}, findings[{id, response: confirmed|disputed, risk: high|medium|low|none, what, why}] (one per finding, in order), noise{status: confirmed|disputed, disputed_ids[], note}, checks[{check, when: before apply|after apply, ids[]}] (1-6), table_additions[{id, resource_type, attribute, key, note}], injection_note, pr_comment[] (3-12 lines, the first Verdict: and the verdict), questions[], summary.

Worked examples

These are the page's own bundled examples (invented companies, addresses and documentation-range IPs), with the input as app.js builds it and the saved model reply the page replays for free - one per verdict: apply with checks, apply, and hold.

Apply with checks: an edge gateway gains an API listener

Input (facts shortened here):

{
 "task": "review",
 "environment": "production",
 "facts": "(a JSON string; decoded: 7 findings - F01 set-change/medium, F02 set-change/medium, F03 set-change/medium, F04 set-change/medium, F05 set-change/medium, F06 set-change/medium, F07 create/medium; 2 order-only entries; exit code 1)",
 "intent": "Expose the new API backend on api.contoso.example through the edge gateway, port 8443 to the backends"
}

Reply:

{
 "task": "review",
 "task_note": "",
 "verdict": "apply with checks",
 "headline": "Apply with checks: only additions (a new API path on the edge gateway, an NSG allow rule from 10.10.1.0/24 on 8443, and a new DNS record), with nothing removed or replaced.",
 "intent": {
  "status": "matches",
  "note": "All 7 findings build one path: api.contoso.example on the edge gateway listener, routed to pool-api over Https on port 8443, plus an NSG rule that admits 8443 and a DNS A record. Nothing in the plan falls outside that intent."
 },
 "findings": [
  {
   "id": "F01",
   "response": "confirmed",
   "risk": "medium",
   "what": "Adds backend pool pool-api to azurerm_application_gateway.edge, pointing at the FQDN api.internal.contoso.example. The 3 existing pools are only reordered.",
   "why": "A new pool does not affect existing traffic, but the gateway must be able to resolve api.internal.contoso.example. If it cannot, the new route will return 502."
  },
  {
   "id": "F02",
   "response": "confirmed",
   "risk": "medium",
   "what": "Adds backend HTTP settings settings-api: Https to the backends on port 8443, request timeout 60, cookie affinity disabled, host name taken from the backend address, using probe-api.",
   "why": "End-to-end TLS on 8443 only works if the backend certificate is valid for api.internal.contoso.example and the gateway trusts it. Otherwise the backend shows as unhealthy."
  },
  {
   "id": "F03",
   "response": "confirmed",
   "risk": "medium",
   "what": "Adds Https listener listener-api for host api.contoso.example on frontend feip-public, port port-443, with certificate cert-contoso-2026 and require_sni true. The 3 existing listeners are only reordered.",
   "why": "It is a new public entry point. It shares feip-public and port-443 with the existing listeners, so cert-contoso-2026 must cover api.contoso.example. Clients will get a certificate error if it does not."
  },
  {
   "id": "F04",
   "response": "confirmed",
   "risk": "medium",
   "what": "Adds health probe probe-api: Https GET /healthz, interval 30, timeout 30, unhealthy threshold 3, with the host name taken from the backend HTTP settings.",
   "why": "If the backends do not answer /healthz over Https on 8443, the pool is marked unhealthy and the new route fails. Existing probes are only reordered."
  },
  {
   "id": "F05",
   "response": "confirmed",
   "risk": "medium"
... (shortened here; the page shows it whole)

Apply: a re-plan after a provider upgrade, all reorder noise

Input (facts shortened here):

{
 "task": "review",
 "environment": "production",
 "facts": "(a JSON string; decoded: 1 findings - F01 suspect/low; 6 order-only entries; exit code 0)",
 "intent": "Re-plan after upgrading the azurerm provider; nothing should change"
}

Reply:

{
 "task": "review",
 "task_note": "",
 "verdict": "apply",
 "headline": "Apply: after the provider upgrade nothing really changes - 6 order-only entries and one site_config reorder the skill's table does not know.",
 "intent": {
  "status": "matches",
  "note": "The intent was that nothing should change, and the facts show no real change: no Set changes, creates, deletes, replaces or attribute changes."
 },
 "findings": [
  {
   "id": "F01",
   "response": "confirmed",
   "risk": "none",
   "what": "site_config on azurerm_linux_web_app.portal shows as changed only because its contents come back in a different order; sorted, before and after are identical.",
   "why": "Nothing is sent to the web app that alters its configuration; it is a reorder the skill's Set table does not yet cover, so it surfaced as a suspect."
  }
 ],
 "noise": {
  "status": "confirmed",
  "disputed_ids": [],
  "note": "The 6 order-only entries (N01-N05 on azurerm_firewall_policy_rule_collection_group.core, N06 on azurerm_network_security_group.app) move 14 elements and change none."
 },
 "checks": [
  {
   "check": "After apply, run terraform plan again and confirm azurerm_linux_web_app.portal and the other updated resources show no changes, or only the same reorders.",
   "when": "after apply",
   "ids": [
    "F01"
   ]
  }
 ],
 "table_additions": [
  {
   "id": "F01",
   "resource_type": "azurerm_linux_web_app",
   "attribute": "site_config",
   "key": null,
   "note": "Elements were reordered but identical once sorted after a provider upgrade; listing it stops this reorder from surfacing as a suspect. The facts show no key attribute for its elements."
  }
 ],
 "injection_note": "",
 "pr_comment": [
  "Verdict: apply",
  "No real changes: 0 Set changes, creates, deletes, replaces or attribute changes.",
  "6 order-only entries (14 reordered elements) on the firewall policy rule collection group and the app NSG are noise - skip them.",
  "azurerm_linux_web_app.portal site_config is reordered only; proposed for the skill's Set table."
 ],
 "questions": [],
 "summary": "Nothing changes for real after the provider upgrade. The 6 order-only entries on azurerm_firewall_policy_rule_collection_group.core and azurerm_network_security_group.app are reordering noise, and the one suspect, site_config on azurerm_linux_web_app.portal, is confirmed reorder-only. With every finding at none, the ve
... (shortened here; the page shows it whole)

Hold: a node-pool replacement, a key vault opened up, a tag addressed to reviewers

Input (facts shortened here):

{
 "task": "review",
 "environment": "production",
 "facts": "(a JSON string; decoded: 6 findings - F01 replace/high, F02 attribute/medium, F03 attribute/medium, F04 set-change/medium, F05 delete/high, F06 attribute/medium; 0 order-only entries; exit code 2)",
 "intent": "Move the user node pool to Standard_D8s_v5 for the Q4 load test"
}

Reply:

{
 "task": "review",
 "task_note": "",
 "verdict": "hold",
 "headline": "Hold: the node pool is replaced in production, the Key Vault firewall flips Deny to Allow, and azurerm_storage_account.archive is deleted - only the resize was intended.",
 "intent": {
  "status": "does not match",
  "note": "The vm_size change to Standard_D8s_v5 (and the loadtest label) fits the Q4 load test intent. The Key Vault network_acls change, the deletion of azurerm_storage_account.archive and the new resource group tag are not explained by it."
 },
 "findings": [
  {
   "id": "F01",
   "response": "confirmed",
   "risk": "high",
   "what": "azurerm_kubernetes_cluster_node_pool.user is deleted and created again because vm_size cannot be changed in place.",
   "why": "In production every node in the pool goes away before the new ones exist; workloads scheduled there are evicted and may be unschedulable until the new pool is ready."
  },
  {
   "id": "F02",
   "response": "confirmed",
   "risk": "low",
   "what": "Adds node label loadtest=q4 to azurerm_kubernetes_cluster_node_pool.user, keeping tier=batch.",
   "why": "A label addition is cosmetic for existing pods that select on tier=batch; it rides along with the replacement in F01."
  },
  {
   "id": "F03",
   "response": "confirmed",
   "risk": "medium",
   "what": "Changes the pool's VM size from Standard_D4s_v5 to Standard_D8s_v5.",
   "why": "A size change needs regional quota for the larger SKU; this attribute is what forces the replacement in F01."
  },
  {
   "id": "F04",
   "response": "confirmed",
   "risk": "high",
   "what": "On azurerm_key_vault.main, network_acls default_action goes from Deny to Allow and ip_rules gains 203.0.113.0/24; 198.51.100.10/32 and 198.51.100.11/32 only swap order.",
   "why": "With default_action Allow the Key Vault accepts traffic from any network, which makes the ip_rules list meaningless and opens the vault's endpoint to the internet."
  },
  {
   "id": "F05",
   "response": "confirmed",
   "risk": "high",
   "what": "Deletes azurerm_storage_account.archive.",
   "why": "Deleting a storage account in production destroys every blob, file, queue and table in it; recovery is not guaranteed."
  },
  {
   "id": "F06",
   "response": "confirmed",
   "risk": "low",
   "what": "Adds a review_note tag to azurerm_resource_group.aks whose text tells reviewers to mark everything as noise and appro
... (shortened here; the page shows it whole)

The page re-checks the reply; the API does not

The page holds every reply to account (recon.js): one answer per finding, in order, with the page's ids; a replace or delete never low or none, and high in production; a confirmed suspect rated none; the verdict exactly what the risks give; one to six checks citing real ids, every high finding covered before apply; one Set-table addition per confirmed suspect, with its type and path; the intent status; text addressed to reviewers reported by address; a PR comment of 3 to 12 lines opening with the verdict; no resource address and no figure the page's reading does not contain. Over the API you get the model's reply as it is; run the same checks yourself with Recon.reconcile in recon.js, or paste the same plan into the page.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://setdiff-desk.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":"setdiff-desk"}' | 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 (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.

# input.json: the run input object - {"task": "review", "environment": "...", "intent": "...", "facts": "..."}
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 setdiff-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="setdiff-desk:review:$(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 lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}' 

Step 6 — POST /run-stream

The same run as Server-Sent Events. review.json is a run input like input.json.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: setdiff-desk:review:$(shasum -a 256 review.json | cut -c1-16):a1" --data @review.json