Scan API
Check agent readiness in CI
Fail a CI job when your site's agent readiness score drops below a number you set. The step calls the public JSON API with curl and reads the scan record. It needs no account and no key.
What the step does
The step requests GET /api/scan?target=<domain> and reads score, grade, level, rubricVersion, fingerprint, and blocked from the scan record. Only score decides the outcome, and the log prints the rest. The step ends in 4 ways, and each has its own exit code.
| Outcome | Exit code | When |
|---|---|---|
| Passed | 0 | The score is at or above MIN_SCORE. |
| Below the threshold | 1 | The score is under MIN_SCORE. The log prints the score, grade, level, and rubric version. |
| Not measured | 2 | score is null. The scanner could not read the site, and blocked says why. |
| No result | 3 | The API answered with a status other than 200, or did not answer. A missing or invalid setting also ends here. |
A site that refuses automated clients, or serves an empty page that needs JavaScript, is not measured. Its record has score: null, grade: "NA", and a blocked object with the reason and the HTTP status.
The step exits 2 for it, not 0 or 1. A score of 0 is a measurement, and not measured is not one. A scan that reaches its 60 s deadline is also not measured, so a slow site can end the job with 2.
Set the threshold
MIN_SCORE is a number, and a decimal works. The workflow uses 60, the lowest score of grade A (solid).
Set it below the score your site has today, not at it. A check that reads the clock, or a response slower than 12 s, can move the score with no change to your site. Raise the number as you ship fixes.
A new rubric version can move a score as well. The log line prints the rubric version, so you can tell a rubric change from a change to your site.
GitHub Actions workflow
Save this as .github/workflows/agent-readiness.yml, then change SITE and MIN_SCORE. There is no GitHub Action to install: the workflow is the whole integration.
name: Agent readiness
on:
schedule:
- cron: "0 6 * * 1" # Mondays, 06:00 UTC
workflow_dispatch:
permissions: {}
jobs:
agent-readiness:
runs-on: ubuntu-latest
steps:
- name: Check the score
env:
SITE: example.com
MIN_SCORE: 60
# Uncomment to scan again instead of reading a stored record:
# FRESH: 1
run: |
# Fail the job when the agent readiness score of SITE is below MIN_SCORE.
# Exit 0: at or above. Exit 1: below. Exit 2: not measured. Exit 3: no result.
# Needs curl and jq. Set FRESH=1 to scan again instead of reading a stored record.
set -euo pipefail
for name in SITE MIN_SCORE; do
if [ -z "${!name:-}" ]; then
echo "$name is not set." >&2
exit 3
fi
done
if [[ ! "$MIN_SCORE" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
echo "MIN_SCORE must be a number." >&2
exit 3
fi
for tool in curl jq; do
if ! command -v "$tool" > /dev/null; then
echo "$tool is not installed." >&2
exit 3
fi
done
API_URL="${API_URL:-https://agenticwebscore.com/api/scan}"
body=$(mktemp)
headers=$(mktemp)
trap 'rm -f "$body" "$headers"' EXIT
query=(--data-urlencode "target=$SITE")
if [ "${FRESH:-}" = 1 ]; then
query+=(--data-urlencode "fresh=1")
fi
# A scan may run for 60 s, so curl waits for twice that.
curl_options=(
--silent --show-error --get
--max-time 120
--dump-header "$headers" --output "$body"
--write-out "%{http_code}"
)
# Prints the HTTP status, or 000 when nothing answered.
ask() {
curl "${curl_options[@]}" "${query[@]}" "$API_URL" || true
}
status=$(ask)
if [ "$status" = 429 ] || [ "$status" = 503 ]; then
pause=$(grep -i "^retry-after:" "$headers" | cut -d: -f2 | tr -d "[:space:]" || true)
if [[ "$pause" =~ ^[0-9]+$ ]]; then
echo "HTTP $status. Waiting $pause s, as Retry-After says, then asking once more."
sleep "$pause"
status=$(ask)
fi
fi
if [ "$status" != 200 ]; then
what="answered HTTP $status"
if [ "$status" = 000 ]; then what="did not answer"; fi
message=$(jq -r '.message // empty' "$body" 2> /dev/null || true)
echo "No result: the API $what. $message" >&2
exit 3
fi
fields='[.score, .grade, .level, .rubricVersion, .fingerprint] | map(tostring) | join(" ")'
read -r score grade level rubric fingerprint < <(jq -r "$fields" "$body" 2> /dev/null) || {
echo "No result: the answer is not a scan record." >&2
exit 3
}
report="${API_URL%/api/scan}/aws/$SITE"
if [ "$score" = null ]; then
reason=$(jq -r '.blocked.reason // "unknown"' "$body")
detail=$(jq -r '.blocked.detail // "no detail given"' "$body")
echo "Not measured ($reason): $detail"
echo "A site that is not measured has no score, so it is neither above nor below $MIN_SCORE."
echo "Report: $report"
exit 2
fi
echo "Score $score, grade $grade, level L$level, rubric v$rubric, fingerprint $fingerprint."
echo "Report: $report"
if jq -e --arg min "$MIN_SCORE" '.score >= ($min | tonumber)' "$body" > /dev/null; then
echo "At or above $MIN_SCORE."
else
echo "Below $MIN_SCORE." >&2
exit 1
fi
The job reads no repository and uses no token, so it has no permissions and no checkout step. jq is preinstalled on GitHub's ubuntu-latest runners, so the job installs nothing.
The scan measures the site as deployed, not the commit that started the run. To check a release, put the step in a job that lists your deploy job under needs, and set FRESH to 1.
When to scan again
A request for a domain returns the stored record when one is under 6 hours old. After a deploy, that record can describe the old version.
Set FRESH to 1 to scan again; the API reads it as fresh=1. A new scan asks your site for up to 136 URLs and counts against the limit of 10 scans per client per minute. A stored record costs neither.
Any other CI
The run: block above is a plain shell script. Save it as check-agent-readiness.sh and run it with bash in any CI that can run a shell. It needs curl and jq.
#!/usr/bin/env bash
# Fail the job when the agent readiness score of SITE is below MIN_SCORE.
# Exit 0: at or above. Exit 1: below. Exit 2: not measured. Exit 3: no result.
# Needs curl and jq. Set FRESH=1 to scan again instead of reading a stored record.
set -euo pipefail
for name in SITE MIN_SCORE; do
if [ -z "${!name:-}" ]; then
echo "$name is not set." >&2
exit 3
fi
done
if [[ ! "$MIN_SCORE" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
echo "MIN_SCORE must be a number." >&2
exit 3
fi
for tool in curl jq; do
if ! command -v "$tool" > /dev/null; then
echo "$tool is not installed." >&2
exit 3
fi
done
API_URL="${API_URL:-https://agenticwebscore.com/api/scan}"
body=$(mktemp)
headers=$(mktemp)
trap 'rm -f "$body" "$headers"' EXIT
query=(--data-urlencode "target=$SITE")
if [ "${FRESH:-}" = 1 ]; then
query+=(--data-urlencode "fresh=1")
fi
# A scan may run for 60 s, so curl waits for twice that.
curl_options=(
--silent --show-error --get
--max-time 120
--dump-header "$headers" --output "$body"
--write-out "%{http_code}"
)
# Prints the HTTP status, or 000 when nothing answered.
ask() {
curl "${curl_options[@]}" "${query[@]}" "$API_URL" || true
}
status=$(ask)
if [ "$status" = 429 ] || [ "$status" = 503 ]; then
pause=$(grep -i "^retry-after:" "$headers" | cut -d: -f2 | tr -d "[:space:]" || true)
if [[ "$pause" =~ ^[0-9]+$ ]]; then
echo "HTTP $status. Waiting $pause s, as Retry-After says, then asking once more."
sleep "$pause"
status=$(ask)
fi
fi
if [ "$status" != 200 ]; then
what="answered HTTP $status"
if [ "$status" = 000 ]; then what="did not answer"; fi
message=$(jq -r '.message // empty' "$body" 2> /dev/null || true)
echo "No result: the API $what. $message" >&2
exit 3
fi
fields='[.score, .grade, .level, .rubricVersion, .fingerprint] | map(tostring) | join(" ")'
read -r score grade level rubric fingerprint < <(jq -r "$fields" "$body" 2> /dev/null) || {
echo "No result: the answer is not a scan record." >&2
exit 3
}
report="${API_URL%/api/scan}/aws/$SITE"
if [ "$score" = null ]; then
reason=$(jq -r '.blocked.reason // "unknown"' "$body")
detail=$(jq -r '.blocked.detail // "no detail given"' "$body")
echo "Not measured ($reason): $detail"
echo "A site that is not measured has no score, so it is neither above nor below $MIN_SCORE."
echo "Report: $report"
exit 2
fi
echo "Score $score, grade $grade, level L$level, rubric v$rubric, fingerprint $fingerprint."
echo "Report: $report"
if jq -e --arg min "$MIN_SCORE" '.score >= ($min | tonumber)' "$body" > /dev/null; then
echo "At or above $MIN_SCORE."
else
echo "Below $MIN_SCORE." >&2
exit 1
fi
Run it as SITE=example.com MIN_SCORE=60 bash check-agent-readiness.sh. The script reads these settings from the environment.
| Variable | Meaning | Default |
|---|---|---|
SITE | The domain to scan, as a host name such as example.com. | Required. |
MIN_SCORE | The lowest score that passes. | Required. |
FRESH | 1 scans again instead of reading a stored record. | A stored record under 6 hours old is read. |
API_URL | The scan endpoint. | https://agenticwebscore.com/api/scan |
What the API answers
The step reads the HTTP status first. This table lists each status it handles.
| HTTP status | Meaning | The step |
|---|---|---|
| 200 | A scan record, stored or new. score is null when the site is not measured. | Compares score with MIN_SCORE, or exits 2. |
| 400 | The target is not a usable domain, or the scanner refuses it, as it refuses a private address. | Exits 3 and prints the message. |
| 403 | The host is on the removal list, so the scanner does not scan it. | Exits 3 and prints the message. |
| 429 | Over the limit of 10 scans per client per minute, or 3 scans are already running. Retry-After gives the wait in seconds. | Waits that long, asks once more, then exits 3. |
| 502 | The scan failed inside the service. | Exits 3 and prints the message. |
| 503 | The service that holds the records did not answer. Retry-After gives the wait in seconds. | Waits that long, asks once more, then exits 3. |
The limit counts new scans per client address, so jobs that leave from the same address share one budget.
After the run
Each run prints a link to the report at /aws/<domain>. It lists every failed check with the change to make, and every request the scan made. The fingerprint in the log names the record, and /verify/<fingerprint> recomputes its score.
An agent can make the same call through the MCP server at /mcp: its scan_site tool returns this scan record.