# Check agent readiness in CI with a GitHub Actions workflow

> Fail a CI job when your site's agent readiness score drops below a number you set. A GitHub Actions workflow and a shell script read the public JSON API.

- Source: /ci
- Site: AgenticWebScore
- Language: English

---
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.

_The outcomes of the step_
| 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.

_.github/workflows/agent-readiness.yml_
```yaml
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`.

_check-agent-readiness.sh_
```bash
#!/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.

_The settings of the script_
| 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.

_The statuses the step 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.
