Skip to main content
UNPWNED
Back to the Skill Scanner

Skill Scanner API

Scan an agent skill from a pipeline or your own tools, before it is installed. The API runs the same engine and returns the same report as the Skill Scanner page, without the browser check. It reads the files as text and never runs them, and it stores nothing: not the skill, not the report.

The short version

Create a key under Settings, API keys with the purpose "Skill scanner", send it as a Bearer token to POST /api/skill-scan/v1 with a public GitHub link or the skill's files, and read report.verdict. Contract version 1.

curl -s https://www.unpwned.io/api/skill-scan/v1 \
  -H "Authorization: Bearer $UNPWNED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"source":{"type":"github","url":"https://github.com/acme/skills/tree/main/pdf"}}' \
  | jq -r '.report.verdict'

Keys and quota

  • Any account can create a Skill Scanner key. The full key is shown once; we keep a hash and a short preview, and you can revoke it at any time.
  • A key has exactly one purpose. A key created for MCP or CI/CD is refused here, and a Skill Scanner key is refused by those endpoints, so a leaked key opens one door.
  • Up to 50 scans in any rolling 24 hours on the Free plan and 500 on paid plans, counted per account across all of its Skill Scanner keys. A refused or failed request costs nothing; only a completed scan counts (the one exception is a request the counter itself refuses at the limit, which the counter records).
  • Every response carries quota.used, quota.limit and quota.windowHours. Each key may also send at most 10 requests a minute, and GitHub downloads share a budget with the page; when it is used up, send the files instead of a link.

Request

A public GitHub repository, skill folder or SKILL.md link:

POST https://www.unpwned.io/api/skill-scan/v1
Authorization: Bearer cvk_your_key
Content-Type: application/json

{ "source": { "type": "github", "url": "https://github.com/acme/skills/tree/main/pdf" } }

Or the skill as it sits on disk, every text file under the skill folder with its relative path:

{
  "source": {
    "type": "files",
    "files": [
      { "path": "SKILL.md", "content": "---\nname: pdf\n..." },
      { "path": "scripts/run.sh", "content": "#!/bin/sh\n..." }
    ]
  }
}

Limits for files: up to 400 files, 1 MB per file (a larger file is listed as skipped rather than read in part), 3.5 MB in total, a 4 MB request body. Paths are plain relative paths: no leading slash, no .., no backslashes, no duplicates. A file you leave out (a binary, or a text file over the cap) can be declared instead of sent, as { "path", "bytes", "omitted": "binary" | "too_large" }, so the coverage block counts it the way a GitHub scan would; the CLI does this for you. When the link or the files hold several skills, the answer is kind: "choose_skill" with a list, and no scan is counted; send one skill's folder.

Response

The report is the same shape the page renders: verdict, grade, score, coverage, capabilities, hosts, and findings with rule ids, confidence, what to check and evidence lines. The sample below leaves some fields out, marked with dots; every field in the live response is always present. A GitHub source also carries the owner, the repository, the path and the commit that was scanned.

{
  "contractVersion": 1,
  "kind": "report",
  "source": { "type": "files", "files": 2 },
  "report": {
    "engineVersion": "skill-engine/0.2.0",
    "verdict": "review_before_installing",
    "grade": "C",
    "score": 72,
    "skill": { "name": "pdf", "description": "...", "path": "", "frontmatterValid": true },
    "coverage": { "filesSeen": 2, "filesScanned": 2, "effectiveCoverage": 1, "checksSkipped": ["provenance_unavailable_for_paste"], "...": "..." },
    "capabilities": ["network"],
    "hosts": [],
    "hostsTotal": 0,
    "findings": [
      {
        "ruleId": "A4-REMOTE-INSTRUCTIONS",
        "severity": "critical",
        "confidence": "likely",
        "title": "Instructions loaded from a remote URL at run time",
        "whatToCheck": "Fetch the URL yourself, read it, and remember it can be different tomorrow.",
        "evidence": [{ "file": "SKILL.md", "line": 12, "snippet": "...", "...": "..." }],
        "evidenceTotal": 1,
        "...": "..."
      }
    ]
  },
  "quota": { "used": 8, "limit": 50, "windowHours": 24 }
}

Verdicts and exit codes

no_indicatorsNothing matched. The grade and score are published.exit 0
review_before_installingAt least one solid medium or worse finding, or a critical at any confidence. Read the evidence lines.exit 1
high_risk_indicatorsA confirmed critical, or two or more solid highs.exit 2
insufficient_coverageToo little of the text could be read, or the check set was cut short; no grade. A confirmed critical still wins. Look at coverage, do not block.exit 0 with a flag

insufficient_coverage is deliberately not a failure: "could not read" is not "dangerous", and a pipeline that blocks on an oversized data file blocks the wrong thing. A "solid" finding has confidence likely or confirmed; a finding at confidence possible is a weak signal. The verdict already ignores weak signals, except a critical one, which still asks for a review; a gate should read the verdict, not the findings.

Errors

Every error is { "error": "...", "code": "..." }, with quota on quota refusals.

400INVALID_BODY, INVALID_INPUT, INVALID_URL, INVALID_PATHThe request cannot be used as sent.
401UNAUTHORIZEDNo key, a malformed key, or a key that does not exist.
403KEY_SCOPEA key created for another purpose (MCP or CI/CD).
404NO_SKILL, NOT_FOUND, EMPTY_REPOSITORYNothing to scan at that location.
413TOO_LARGEOver the body, bundle or repository limits.
429RATE_LIMIT, QUOTA_EXCEEDED, DOWNLOAD_LIMITSlow down, wait for the window, or send the files instead of a link.
502, 503UPSTREAM_ERROR, UPSTREAM_RATE_LIMITGitHub did not answer. Retry later.
500INTERNALOur side. Retry once, then write to [email protected].

What the API does not do

  • It never executes a skill. Scripts are read as text, the same as on the page.
  • It stores nothing: no skill, no file, no report. The only things kept are the time the key was last used and short-lived rate-limit counters, which are deleted automatically; the Privacy Policy, Section 2.14, gives the timing. Uploaded files have no provenance, so the coverage block says so.
  • contractVersion moves only when the meaning of an existing field changes. Adding a field, a verdict reason or an error code does not bump it. Pin your parser to the fields you read.
  • A clean result is one input. Static analysis finds known patterns, the next commit can change everything, and a verdict is not proof of intent either way. Read what you install.

How the tool handles what you send is described in the Privacy Policy, Section 2.14.