# API for AI agents

Source: https://docs.askaniela.com/user/agents/

How an AI agent, a script or a workflow tool uses the AskAniela API step by step: key, one page, a site audit,
search intent, costs, errors and the JSON of the results.

Every example on this page was run against our staging API before publication. The Markdown version of this page
is at `https://docs.askaniela.com/user/agents.md`.

## Who this is for {#who}

- **AI agents with an HTTP tool** (Claude, ChatGPT, Codex, Cursor and similar): give the agent this page, the
  [example system prompt](#system-prompt) and a key in an environment variable.
- **Scripts** (Python, Node, shell): copy the [curl](#one-page) or [Python](#python) examples.
- **Workflow tools** (n8n, Make, Zapier): use the HTTP Request node with `Authorization: Bearer gsj_...`, a Wait
  node and a loop on the status field.
- **MCP**: an AskAniela MCP server is planned on top of the same API, with the same keys and limits. Until then,
  connect the API as an HTTP tool or generate tools from `openapi.json` (see [What agents can read](#llms-openapi)).

## Before the first call {#start}

| What | Value |
|---|---|
| Base URL | `https://api.askaniela.com` |
| Header | `Authorization: Bearer gsj_...` on every request (no key: `401 unauthorized`) |
| Body | JSON, `Content-Type: application/json` |
| Key | a project key from the project's **API keys** tab ([API keys](https://docs.askaniela.com/user/api-keys.md)) |
| Scopes | `judge`, `reports:read`, `jobs` (the defaults) cover everything on this page |

Keys created before the defaults changed (October 2026) may lack `jobs`. Then site audits answer `403 insufficient_scope` with
`error.details.required_scope`; create a new key with the default scopes. Give each agent its own key with an
expiry date, keep it in an environment variable and never put it into a prompt, a log or a chat.

```bash
export ASKANIELA_URL=https://api.askaniela.com
export ASKANIELA_API_KEY=gsj_...
```

### Map of tasks {#map-of-tasks}

| Task | Call | Scope | Answer | Paid |
|---|---|---|---|---|
| Check one page | `POST /v1/judge` | `judge` | `200`, report in the body (1-3 min) | yes |
| Check up to 10 pages | `POST /v1/judge/batch` | `judge` | `200`, `results[]` | yes |
| Price of a site audit | `POST /v1/audits/site/preview`, then `/estimate` | `jobs` | `200` | no |
| Site audit | `POST /v1/audits/site` | `jobs` | `202`, then poll | yes |
| Audit status | `GET /v1/audits/{id}` | `reports:read` | `200` | no |
| Audit results | `GET /v1/audits/{id}/export.json` | `reports:read` | `202` while prepared, then `200` | no |
| Fix plan | `GET /v1/audits/{id}/fix-plan?format=md` | `reports:read` | `202` while generated, then Markdown | no |
| Search intent | `POST /v1/intent` | `judge` | `202`, then `GET /v1/intent/{id}` | yes |
| Balance and limits | `GET /v1/usage` | any | `200` | no |
| Prices | `GET /v1/tiers` | none | `200` | no |

## 1. Check one page {#one-page}

```bash
curl -s "$ASKANIELA_URL/v1/judge" \
  -H "Authorization: Bearer $ASKANIELA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/"}' \
  --max-time 300 -D headers.txt -o report.json
grep -i x-cost-usd headers.txt     # x-cost-usd: 0.0665
```

- Send exactly one of `url`, `html` or `text`. Optional: `modes` (`["spam"]`, `["quality"]`, `["helpful"]` or a
  mix; fewer modes cost less), `rules` (ids from `GET /v1/rules`), `render_js`, `explain` (evidence and fixes for
  hits), `report_lang` (`en` or `pl`), `project_id`.
- The call is synchronous. One page with the full catalogue took about 2 minutes on staging; fewer `modes` are
  faster. Set the client read timeout to at least 300 seconds.
- The body has no report id. Find the stored report with
  `GET /v1/reports?url=https://example.com/&limit=1` (`id` = `rep_…`), then `GET /v1/reports/{id}/fix-plan?format=md`.
- More than one page: `POST /v1/judge/batch` with `{"items": [{"url": …}, …]}` (up to 10), or a site audit.

## 2. Site audit {#site-audit}

A site audit checks a sample of pages from a sitemap or your URL list. It runs in the background.

**Step 1: preview** (free). Finds the sitemap and groups URLs into patterns.

```bash
curl -s "$ASKANIELA_URL/v1/audits/site/preview" \
  -H "Authorization: Bearer $ASKANIELA_API_KEY" -H "Content-Type: application/json" \
  -d '{"site": "example.com"}'
# {"preview_id": "7963…", "total": 94, "patterns": [{"pattern": "/user/<slug>", "count": 11, …}], …}
```

**Step 2: estimate** (free, nothing is started). Send the body you want to run, with `preview_id`.

```bash
curl -s "$ASKANIELA_URL/v1/audits/site/estimate" \
  -H "Authorization: Bearer $ASKANIELA_API_KEY" -H "Content-Type: application/json" \
  -d '{"site": "example.com", "preview_id": "7963…", "pages": 5,
       "scope": {"include_patterns": ["/user/<slug>"]}}'
# {"selected": 11, "pages": 5, "rules": 119, "cost_usd": 0.3325, "cost_per_page_usd": 0.0665, "seconds": 50, …}
```

**Step 3: ask the person.** Show `cost_usd`, `pages` and the time, and wait for a yes. An agent should never
start an audit on its own.

**Step 4: start** (paid). Send the same body once.

```bash
curl -s "$ASKANIELA_URL/v1/audits/site" \
  -H "Authorization: Bearer $ASKANIELA_API_KEY" -H "Content-Type: application/json" \
  -d '{"site": "example.com", "preview_id": "7963…", "pages": 5,
       "scope": {"include_patterns": ["/user/<slug>"]}}'
# 202 {"audit_id": "aud_2fptzm5h3hiruh6c", "job_id": "job_e74cap43wdgzyszz", "pages": 5, "patterns": 1}
```

Do not repeat this POST after a timeout: first look for the audit in `GET /v1/audits?limit=5`
(see [Retrying safely](#retrying)).

**Step 5: wait.** Poll `GET /v1/audits/{audit_id}` every 15-30 seconds until `progress.status` is final.
The status endpoint sends no `Retry-After`, so pick the interval yourself; `eta_seconds` gives the expected time.

```bash
curl -s "$ASKANIELA_URL/v1/audits/aud_2fptzm5h3hiruh6c" -H "Authorization: Bearer $ASKANIELA_API_KEY"
# {"progress": {"status": "running", "total": 5, "done": 1, "failed": 0, …}, "eta_seconds": 38, …}
```

| `progress.status` | Final | What to do |
|---|---|---|
| `queued`, `running` | no | keep polling |
| `pausing`, `paused`, `cancelling` | no | someone paused or stopped it; `POST /v1/audits/{id}/resume` continues a paused audit |
| `done` | yes | read the results; `failed > 0` means some pages could not be checked |
| `failed` | yes | the audit stopped (`error`); pages not judged go back to the balance |
| `cancelled` | yes | stopped for good; judged pages keep their reports |

The 5-page audit above took about 6 minutes on staging. The same 8 states and transitions are described in
[Job statuses](https://docs.askaniela.com/guide/webhooks.md#job-statuses).

Instead of polling you can receive a webhook. An audit has no `webhook_url` field; an organization owner or admin
registers an [organization webhook](https://docs.askaniela.com/guide/developer-portal.md#organization-webhooks) once, and it receives
`job.finished` with the audit's `job_id`. An agent that only has a key should poll.

**Step 6: results.** `export.json` is prepared in the background: the first request may answer
`202 {"status": "pending", "retry_after": 5}` with a `Retry-After` header. Wait that many seconds and repeat
the GET.

```bash
curl -s "$ASKANIELA_URL/v1/audits/aud_2fptzm5h3hiruh6c/export.json" -H "Authorization: Bearer $ASKANIELA_API_KEY"
```

**Step 7: fix plan** in Markdown, ready to hand to a person or another agent. Same `202` + `Retry-After` on the
first request; later requests read the stored plan for free.

```bash
curl -s "$ASKANIELA_URL/v1/audits/aud_2fptzm5h3hiruh6c/fix-plan?format=md" -H "Authorization: Bearer $ASKANIELA_API_KEY"
# # Fix plan: example.com
# Source: site audit aud_2fptzm5h3hiruh6c · Generated: 2026-10-20 20:12 UTC
```

Also available: `export.zip` (one Markdown file per page plus `audit.json`), `export.csv`, `export.html`,
`export.pdf`, and `fix-plan?format=json`.

## 3. Search intent for a phrase {#intent}

```bash
curl -s "$ASKANIELA_URL/v1/intent?wait=60" \
  -H "Authorization: Bearer $ASKANIELA_API_KEY" -H "Content-Type: application/json" \
  -d '{"query": "best running shoes", "market": "en-US"}'
# 202 {"id": "itr_…", "status": "running", "tier": "basic", "price_usd": 0.0015, "report": null, …}
```

- The answer is `202` with `status` `queued`, `running`, `done` or `failed`. `?wait=N` (up to 60) waits for
  the result; if it is not ready, poll `GET /v1/intent/{id}` every 15 seconds until `done` or `failed`.
- `price_usd` is the charge for this phrase. It is lower when recent search results are reused
  (`freshness: auto`, the default); `fresh` always fetches new results at full price.
- `tier`: `basic` (search intent and the best form of page), `premium` (Ani also reads the top pages) or
  `compare` (your page in `compare_url` next to them). Prices: `GET /v1/tiers` (`intent[]`).
- Many phrases: `POST /v1/intent/batch` (JSON `items`) or `POST /v1/intent/batch/csv`.

## Costs and limits {#costs}

- **Price before ordering**: site audits have `POST /v1/audits/site/estimate`; page and intent prices are in
  `GET /v1/tiers` and in [Balance and pricing](https://docs.askaniela.com/user/balance-pricing.md).
- **Cost of a call**: every paid answer has the header `X-Cost-USD` (for example `0.0665` for one page with the
  full catalogue, `0.0315` for spam only). For audits and jobs it is the amount reserved at the start; pages that
  were not judged are refunded later.
- **Balance**: `GET /v1/usage` shows what was used this day and month, the limit and the balance in USD.
- **Limit reached**: `429 quota_exceeded` with `details` (`limit_usd`, `used_usd`, `requested_usd`,
  `remaining_usd`). The API does not use `402`; a missing balance is always `429 quota_exceeded`. Stop and tell
  the person; retrying will not help.
- **Too many requests**: `429 rate_limited` with `Retry-After` and `RateLimit-*` headers. Wait and retry.
- **Active work**: up to 2 audits or jobs run at once per organization (`429 too_many_active_jobs`). Wait for one
  to finish.

## Errors {#errors}

Every error from `/v1/*` has one shape. Branch on `code`, never on `message`.

```json
{"error": {"code": "unknown_rule", "message": "Unknown rules: NOPE",
           "details": {"rules": ["NOPE"]}, "request_id": "37da8d48f0e74f579f984e1e1cf215e1"}}
```

`message` is in English; with the header `Accept-Language: pl` it is in Polish. `code` never changes.

| HTTP | `code` | What the agent does |
|---|---|---|
| 400 | `unknown_rule`, `rule_disabled`, `invalid_url`, `invalid_webhook_url` | fix the input; do not retry as is |
| 401 | `unauthorized`, `invalid_api_key`, `api_key_expired` | stop and ask the person for a valid key |
| 403 | `insufficient_scope` | stop; the person creates a key with `details.required_scope` |
| 403 | `ip_not_allowed`, `org_suspended`, `forbidden` | stop and tell the person |
| 404 | `report_not_found`, `audit_not_found`, `job_not_found`, `intent_not_found` | check the id (`rep_`, `aud_`, `job_`, `itr_`) |
| 409 | `job_not_controllable`, `audit_running`, `upgrade_unavailable` | the state does not allow this action; read the status |
| 413 | `batch_too_large`, `audit_too_large`, `item_too_large` | split the work, or use a site audit instead of a batch |
| 422 | `validation_error` | fix the body; `details[]` lists the fields |
| 422 | `sitemap_not_found`, `empty_selection`, `preview_expired`, `invalid_scope` | change the source or scope; run preview again |
| 429 | `rate_limited`, `too_many_active_jobs` | wait `Retry-After` seconds, then retry |
| 429 | `quota_exceeded` | stop; the limit or balance is used up |
| 502 | `fetch_failed` | the page could not be fetched; tell the person, do not loop |
| 502 | `inference_failed` | no rule could be scored, nothing charged; retry once later |
| 503 | `spend_cap_reached`, `beta_budget_reached`, `queue_unavailable`, `unavailable` | wait `Retry-After` (often 3600 s) |
| 503 | `fix_plan_unavailable`, `export_unavailable` | nothing charged; retry the GET later |

A `502` or `504` **without** a JSON body comes from the gateway, for example during a deploy. For a GET, retry
after 30 seconds. For a POST, do not retry blindly (next section). Every answer has `X-Request-ID`; quote it
when you report a problem. All codes: [Errors](https://docs.askaniela.com/reference/errors.md).

## Retrying safely {#retrying}

The API has no `Idempotency-Key` yet, so a repeated POST is new work and a new charge.

- **GET** can always be repeated.
- **`POST /v1/judge`**: a repeat checks and charges the page again. After a timeout, look for the report in
  `GET /v1/reports?url=…&limit=1` first.
- **`POST /v1/audits/site`, `POST /v1/jobs`**: after a timeout or a gateway error, check `GET /v1/audits?limit=5`
  or `GET /v1/jobs?limit=5`. The audit is usually already there.
- **`POST /v1/intent`**: check `GET /v1/intent?q=<phrase>&limit=5`. A phrase stuck in `running` is closed by the
  system as `failed` with a refund after about 40 minutes; do not order it again before that.
- Send your own `X-Request-ID` (`[A-Za-z0-9._-]{1,128}`) to match requests with your logs.

## Reading the results {#results}

### One page (`POST /v1/judge`, `GET /v1/reports/{id}`)

| Field | Meaning |
|---|---|
| `violations[]` | spam-policy rules |
| `quality_signals[]` | page quality and helpful-content rules |
| `positives[]`, `labels[]`, `choices[]` | positive signals, context labels (for example YMYL topic) and rating scales |
| `not_assessed[]` | policies that cannot be judged from one page, with `reason` |
| `skipped[]` | rule ids not run for the chosen `modes` or `rules` |
| `fetch`, `extraction_ok`, `truncated` | how the page was read; `extraction_ok: false` means little main text was found |

Each rule has `rule_id`, `policy`, `layer`, `kind`, `ok` (`false` = the rule could not be scored, see `error`) and
`score_raw` from 0 to 1. **Signal strength = `round(score_raw * 100)`, from 0 to 100**: 80 or more is strong,
50-79 medium, below 50 low. Show it as "signal 87 of 100". It is not a percentage, not a probability and not a
Google decision. With `explain: true`, hits get `evidence.quotes[]` (`text`, `verified`: the quote was found in
the page by code) and `evidence.fix`. A hit with `unsupported: true` has no verified quote and needs a human check.
Ignore the internal fields `z`, `coverage`, `top1_other`, `probability`, `entropy`, `samples`, `spread` and
`prompt_sha`; they may change.

### Site audit (`GET /v1/audits/{id}/export.json`, `schema_version` 1.1)

Only confirmed findings are included. Example shape (one finding, shortened):

```json
{
  "schema_version": "1.1",
  "audit": {"id": "aud_…", "status": "done", "pages_total": 5, "pages_checked": 5, "pages_failed": 0,
            "pages_with_findings": 1, "site_levels": {"spam": "high", "quality": "low", "helpful": "low"},
            "note": "1 of 5 pages (20%) have a confirmed spam finding. …"},
  "pages": [{
    "report_id": "rep_…", "url": "https://example.com/best-deals", "pattern": "/<slug>", "risk": "high",
    "findings": 1, "spam_level": "high", "quality_level": "low", "helpful_level": "na",
    "items": [{
      "category": "spam", "rule_id": "SP-AFF", "policy": "Thin affiliation", "title": "…", "level": "high",
      "signal": 91, "evidence": [{"text": "…quoted from the page…", "verified": true}],
      "fix": "…what to change on this page…", "fix_personal": true,
      "sources": [{"label": "…", "url": "https://developers.google.com/search/docs/essentials/spam-policies#thin-affiliate-pages"}]
    }]
  }]
}
```

- `site_levels` come from the share of pages with a confirmed finding: under 2% low, 2-10% medium, over 10% high.
- Page levels are `high`, `medium`, `low`, `na` (criteria do not apply, for example a utility page) or `unknown`.
- `risk` is the highest confirmed level of the page: `high`, `medium` or `none`.
- `signal` is already 0-100. `fix_personal: true` means the fix was written for this page, otherwise it is the
  general advice of the rule. `sources` are the official Google guidelines behind the rule.

## Python example {#python}

Requires `httpx` (`pip install httpx`). It checks one page, prices an audit and shows where to start and follow it.

```python
"""AskAniela API from Python (httpx): one page, a site audit, results and costs."""

import os
import time

import httpx

API = os.environ.get("ASKANIELA_URL", "https://api.askaniela.com")
KEY = os.environ["ASKANIELA_API_KEY"]
FINAL = {"done", "failed", "cancelled"}

client = httpx.Client(
    base_url=API,
    headers={"Authorization": f"Bearer {KEY}"},
    timeout=httpx.Timeout(15.0, read=300.0),  # one page takes up to a few minutes
)


class AnielaError(Exception):
    def __init__(self, status: int, code: str, message: str, retry_after: str | None, request_id: str | None):
        super().__init__(f"{status} {code}: {message} (request_id={request_id})")
        self.status, self.code, self.retry_after, self.request_id = status, code, retry_after, request_id


def call(method: str, path: str, **kw) -> httpx.Response:
    r = client.request(method, path, **kw)
    if r.status_code >= 400:
        is_json = r.headers.get("content-type", "").startswith("application/json")
        err = r.json().get("error", {}) if is_json else {}  # a 502/504 from a proxy has no JSON body
        raise AnielaError(r.status_code, err.get("code", f"http_{r.status_code}"), err.get("message", r.text[:200]),
                          r.headers.get("Retry-After"), err.get("request_id"))
    return r


def cost(r: httpx.Response) -> float:
    return float(r.headers.get("X-Cost-USD", "0"))


def wait_ready(path: str, params: dict | None = None, max_wait: int = 900) -> httpx.Response:
    """GET that may answer 202 + Retry-After while a file is prepared. Repeating a GET is safe."""
    deadline = time.monotonic() + max_wait
    while True:
        r = call("GET", path, params=params)
        if r.status_code != 202:
            return r
        if time.monotonic() > deadline:
            raise TimeoutError(path)
        time.sleep(int(r.headers.get("Retry-After", "10")))


def judge_page(url: str) -> tuple[dict, float]:
    r = call("POST", "/v1/judge", json={"url": url, "modes": ["spam"]})
    return r.json(), cost(r)


def estimate_site_audit(site: str, pages: int) -> dict:
    body = {"site": site, "pages": pages}
    preview = call("POST", "/v1/audits/site/preview", json=body).json()
    body["preview_id"] = preview["preview_id"]
    return {"body": body, "estimate": call("POST", "/v1/audits/site/estimate", json=body).json()}


def start_site_audit(body: dict) -> str:
    """Paid. Call once, after the user accepted the estimate. Never retry this POST blindly."""
    return call("POST", "/v1/audits/site", json=body).json()["audit_id"]


def follow_audit(audit_id: str, every: int = 20) -> tuple[dict, str]:
    while True:
        a = call("GET", f"/v1/audits/{audit_id}").json()
        p = a["progress"]
        print(f"{p['status']}: {p['done']}/{p['total']} pages, failed {p['failed']}")
        if p["status"] in FINAL:
            break
        time.sleep(every)
    export = wait_ready(f"/v1/audits/{audit_id}/export.json").json()
    plan = wait_ready(f"/v1/audits/{audit_id}/fix-plan", params={"format": "md"}).text
    return export, plan


def strongest(report: dict) -> list[tuple[str, int]]:
    """Spam and quality rules with signal strength 0-100 (score x 100), strongest first."""
    rows = [(r["rule_id"], round(r["score_raw"] * 100))
            for key in ("violations", "quality_signals") for r in report[key] if r.get("ok")]
    return sorted(rows, key=lambda x: -x[1])


if __name__ == "__main__":
    print(call("GET", "/v1/usage").json()["org"]["month"])
    report, usd = judge_page("https://example.com/")
    print("cost", usd, "top rules", strongest(report)[:3])
    plan = estimate_site_audit("example.com", pages=5)
    print("estimate", plan["estimate"]["cost_usd"], "USD,", plan["estimate"]["seconds"], "s")
    # audit_id = start_site_audit(plan["body"])  # only after the user said yes
    # export, fix_plan_md = follow_audit(audit_id)
```

## Example system prompt {#system-prompt}

Paste this into the agent's instructions and adjust the limits.

```text
You can use the AskAniela API at https://api.askaniela.com to check web pages against Google spam policies
and page-quality guidelines. The API key is in the environment variable ASKANIELA_API_KEY; send it as
"Authorization: Bearer <key>". Never print, log or repeat the key.

Rules:
1. Check only sites the user owns or is authorised to analyse.
2. Before any paid call, get the price: POST /v1/audits/site/estimate for site audits, GET /v1/tiers for single
   pages and search intent. Tell the user the price and wait for an explicit yes. Never start a site audit on
   your own. Single pages up to $0.10 in total per conversation do not need a separate yes.
3. Never repeat a POST that creates work (judge, audits/site, jobs, intent) without checking first whether the
   first one went through (GET /v1/reports?url=…, /v1/audits?limit=5, /v1/intent?q=…). A repeat is charged again.
4. Repeating GET requests is safe. On 202 wait for Retry-After seconds. Poll audit status every 20-30 seconds.
5. Branch on error.code, never on the message. Stop and ask the user on 401, 403, quota_exceeded and fetch_failed.
   Wait Retry-After on rate_limited, too_many_active_jobs and 503 codes.
6. Report results as signal strength from 0 to 100 (score_raw x 100, or "signal" in exports): "signal 87 of 100".
   Never present it as a percentage, a probability or a Google penalty. Say "may break the policy" or
   "possible quality issue", not "violates".
7. Quote evidence only when verified is true. Use the fix plan (GET /v1/audits/{id}/fix-plan?format=md) for
   recommendations instead of inventing your own.
8. After every paid call, read the X-Cost-USD header and keep a running total for the user.
Docs for agents: https://docs.askaniela.com/llms.txt and https://docs.askaniela.com/user/agents.md
```

## What llms.txt and openapi.json tell agents {#llms-openapi}

| Address | What it gives |
|---|---|
| `https://docs.askaniela.com/llms.txt` | index of all docs pages as Markdown links with one-line summaries ([llms.txt](https://llmstxt.org/) format) |
| `https://docs.askaniela.com/llms-full.txt` | all docs pages in one Markdown file |
| `https://docs.askaniela.com/<page>.md` | any docs page as plain Markdown, for example `https://docs.askaniela.com/user/agents.md`; Polish pages under `/pl/` |
| `https://api.askaniela.com/llms.txt` | short index on the API host: docs links and the OpenAPI address |
| `https://api.askaniela.com/openapi.json` | live OpenAPI 3.1 description of the API |

Known gaps in `openapi.json`, which this page fills in:

- It does not declare the `Bearer gsj_...` key yet; it lists a session cookie and a password login. Agents and
  tool generators should ignore those and send `Authorization: Bearer gsj_...`.
- It has no request examples, errors are described only as `4XX`/`5XX`, and the `X-Cost-USD` and `Retry-After`
  headers are not declared. Use the [Errors](#errors) table and the steps above.
- It lists account, session and admin operations too. Expose to an agent only the operations from the
  [map of tasks](#map-of-tasks).

Next: [API quickstart](https://docs.askaniela.com/user/api-quickstart.md), [API keys](https://docs.askaniela.com/user/api-keys.md), [Site audits guide](https://docs.askaniela.com/guide/site-audits.md),
[Webhooks and jobs](https://docs.askaniela.com/guide/webhooks.md), [Errors](https://docs.askaniela.com/reference/errors.md).
