# AGENTS.md — OpenWarrant for AI agents

A guide for AI agents and automated clients calling **OpenWarrant**. The same file is served
at `https://www.stipple.sh/agents.md` and, in short form, at `https://www.stipple.sh/llms.txt`.

## What this service does

OpenWarrant inspects one document (a PDF or an image) and returns an explainable
**authenticity signal rating** — tampering and AI-generation indicators, arithmetic
reconciliation for financial documents, and provenance/metadata traces.

It does **not** decide fraud. The output is a *signal*, not a verdict; a human (or your
agent) makes the call. There is deliberately no 0–100 "fraud score" — you get discrete
bands plus the evidence behind them. Engine v0.3.0.

Identical documents are cached by content hash: re-submitting the same bytes returns the
stored result instantly and skips the (paid) re-inspection.

> **Read two axes, not one.** Every result has a `risk_band` (how authentic the document
> looks) **and** an `inspection_quality` (how completely we could inspect it). They are
> independent. A clean phone photo of a real payslip is commonly `risk_band: low` +
> `inspection_quality: limited` — "nothing looks tampered, but we couldn't read everything."
> Do not treat low coverage as risk. See *Schema notes* below.

## Base URL

```
https://www.stipple.sh
```

Use the `www.` prefix. The apex domain 307-redirects to `www.`, and a **POST through that
redirect becomes a 405** for non-browser clients — so always call the `www.` host directly.

Works anonymously (no key, no account). A **free API key** gets you a higher daily quota of
your own plus metered usage — see *Rate limits* below. Be polite either way.

## Endpoints

### 1. Inspect a document — `POST /v1/warrants`

The one verb. Send the document one of two ways:

- **multipart** form field `file` (humans, most agents), or
- **JSON** body `{"bytes_b64": "<base64>", "filename": "payslip.pdf"}` (inline callers).

```
curl -F file=@payslip.pdf https://www.stipple.sh/v1/warrants
```

Query parameters:

- `?stream=1` — return a **Server-Sent Events** lifecycle stream instead of one JSON blob
  (recommended for images and any slow inspection — see below).
- `?fresh=1` — bypass the content-hash cache and force a new inspection.

Returns a **WarrantBundle** (full schema under *The result bundle*). On the non-stream path
the bundle is the response body. Errors: `400` empty/unparseable input, `413` over the size
limit, `429` rate limited (with `Retry-After`), `501` if you send `{"url": ...}` (URL intake
is not implemented yet — fetch the bytes yourself and send `file`/`bytes_b64`).

**Streaming (`?stream=1`)** is `text/event-stream`. The connection stays open through the
multi-second VLM call. It is a *lifecycle* stream (a few stage markers), not per-signal
progress. Event sequence:

```
event: received
data: {"bytes": 48213}

event: inspecting
data: {"profile": "standard"}

event: result
data: { ...the full WarrantBundle... }
```

On failure you get `event: error` with `data: {"detail": "Inspection failed."}` and the
stream closes. The `result` event's `data` is byte-for-byte the same bundle the non-stream
call returns.

### 2. Check before you inspect — `GET /v1/warrants/check?sha256=<64-hex>`

Hash the document yourself (sha256, lowercase hex) and ask whether it has already been
inspected. Lets you skip a redundant, paid call.

```json
{ "cached": true, "warrant_id": "warrant_ac103b88b662afbf", "permalink": "/w/warrant_ac103b88b662afbf" }
```

or `{ "cached": false }`. A malformed hash (not 64 hex chars) returns `400`.

### 3. Fetch / share / export a stored warrant

Every bundle carries a `warrant_id` of the form `warrant_<first-16-hex-of-sha256>` — it is
deterministic, so the same document always resolves to the same id and permalink.

```
GET  https://www.stipple.sh/v1/warrants/<warrant_id>             # the bundle as JSON
GET  https://www.stipple.sh/v1/warrants/<warrant_id>?format=md   # a Markdown report
GET  https://www.stipple.sh/v1/warrants/<warrant_id>/report.pdf  # a branded, audit-ready PDF
```

`404` if no warrant exists for that id, `400` if the id is malformed. Humans can open the
shareable permalink at `https://www.stipple.sh/w/<warrant_id>`.

### 4. Give feedback — `POST /v1/warrants/<warrant_id>/feedback`

Thumbs up/down on a rating (this is the label source that improves the engine).

```json
{ "verdict": "up", "note": "optional free text" }
```

`verdict` must be `"up"` or `"down"`. Returns `{ "status": "recorded", "warrant_id": "...", "verdict": "up" }`.

### 5. Health — `GET /healthz`

```json
{ "status": "ok", "engine_version": "0.3.0", "profile": "standard", "storage": "gcs" }
```

### 6. Detect AI-written text & verify references (REST)

Two analysis endpoints — the same engines as the `/mcp-aitext` and `/mcp-verify` MCP servers below,
as plain REST for web/HTTP callers. Each accepts a multipart `file` (PDF/text), or a JSON body with
`text`, `bytes_b64` (+ optional `filename`), or `url` (a PUBLIC report fetched server-side: public
http(s) hosts only, SSRF-guarded, size-capped). Same per-IP rate limits and size cap.
`detect-ai-text` is stateless (nothing stored); `verify-references` results persist under a
content-hash permalink (below) — the submitted document itself is never stored by either.

The cheapest possible call — fact-check a public report by URL alone:

```
curl -X POST https://www.stipple.sh/v1/verify-references -H "Content-Type: application/json" \
  -d "{\"url\": \"https://example.com/report.pdf\"}"
```

- `POST https://www.stipple.sh/v1/detect-ai-text` — probability a document's PROSE was AI-written. Returns
  `{ applicable, probability, lean, tells, reasoning, limitations }`; abstains (`applicable: false`)
  on forms/tables/scans. The probability is the model's CONFIDENCE, not a calibrated truth.
- `POST https://www.stipple.sh/v1/verify-references` — check that a document's citations resolve and match,
  recompute its internal arithmetic, and flag unsupported/contradicted claims. Also accepts
  `{"url": "https://…"}` to fetch a PUBLIC report directly (public http(s) hosts only,
  SSRF-guarded, size-capped). `?deep=1` adds the slower web claim-entailment pass; `?stream=1`
  returns an SSE lifecycle stream (`received` → `working` … → `result`) so the deep pass
  survives proxy timeouts. Each result persists under a content-hash id; the response includes a
  shareable `permalink` (`https://www.stipple.sh/fc/<slug>-<hex>` — the trailing hex resolves, the slug is
  SEO words), and the raw result is retrievable via `GET https://www.stipple.sh/v1/fact-checks/<check_id>`.
  Reports COVERAGE, not truth.
- `POST https://www.stipple.sh/v1/classify` — classify a FINANCIAL document's type. We specialise in
  financial-services documents: `payslip`, `tax_invoice`, `bank_statement`,
  `salary_certificate`, `payg_summary`, `receipt` — anything else returns `other`, and a
  below-threshold verdict abstains as `unknown` rather than guessing. Send multipart `file`
  (PDF/image), or JSON `bytes_b64` / `url` (a direct document link — fetched raw, SSRF-guarded).
  Returns `{ document_type, country_code, confidence, is_financial_document, evidence,
  limitations }`. Stateless; type only — NOT an authenticity judgment.
- `POST https://www.stipple.sh/v1/feedback` — thumbs up/down on any stateless tool's result:
  `{ "tool": "detect_ai_text" | "verify_references" | "classify_document", "verdict": "up" | "down", "ref": "...", "note": "..." }`.

## MCP server (Streamable HTTP)

**One server, every tool**: add `https://www.stipple.sh/mcp` and any MCP-capable client — Claude Desktop,
Claude Code, Cursor, or your own agent — gets the FULL Stipple suite as native tools instead of
hand-rolling the HTTP above. Hosted, no install, same rate limits (your API key or IP) and
content-hash cache.

```
claude mcp add --transport http openwarrant https://www.stipple.sh/mcp
```

…or in a client's MCP config:

```json
{ "mcpServers": { "openwarrant": { "type": "http", "url": "https://www.stipple.sh/mcp" } } }
```

### The tools on `https://www.stipple.sh/mcp`

- `verify_document(url | bytes_b64, filename)` — forensic authenticity inspection of a document
  supplied as a public http(s) `url` (fetched server-side — the cheapest call) or inline as
  base64. Returns the headline result (`risk_band`, `inspection_quality`,
  `recommended_action`, `summary`, `risk_findings`, `permalink`).
- `classify_document(url | bytes_b64, filename)` — FINANCIAL document-type classification
  (payslip, tax_invoice, bank_statement, salary_certificate, payg_summary, receipt) with
  issuing country and confidence. Abstains (`unknown`) below threshold; non-financial → `other`.
  Type only, never an authenticity judgment — run it first, then `verify_document`.
- `detect_ai_text(text | url | bytes_b64, filename)` — estimate the probability a document's
  **prose** was AI-written, with the linguistic tells. It **abstains on forms/tables/scans**
  (non-prose). A CONFIDENCE, not a calibrated truth — and it judges *writing style*, not
  authenticity (a fake can be hand-typed).
- `verify_references(url | text | bytes_b64, filename, deep)` — for a document (typically an LLM
  deep-research report), check that its **citations resolve and match** (arXiv/Crossref for papers,
  liveness + Wayback for web), **recompute its internal arithmetic**, and flag
  **unsupported/contradicted** claims. `deep=true` adds opt-in web claim-entailment (slower).
  Returns the trust summary, per-item tables, and a shareable `permalink`. Reports verification
  COVERAGE, not truth.
- `check_document(sha256)` — has this exact document already been inspected? Skip a paid call.
- `get_warrant(warrant_id, as_markdown)` — fetch a stored bundle (JSON, or a Markdown report).
- `submit_feedback(warrant_id, verdict, note)` — thumbs up/down on a rating.

Scoped servers `https://www.stipple.sh/mcp-aitext` (detect_ai_text only) and `https://www.stipple.sh/mcp-verify`
(verify_references only) remain available for existing installs — same implementations, same
limits. New installs should just use `/mcp`.

Every hosted tool takes the document as a public **`url`** (fetched server-side through an
SSRF-guarded fetcher: public http(s) hosts only, every redirect hop re-validated, size-capped) or
**inline as base64** — a remote server can't read your filesystem. Prefer `url` when the document
is public: it's one short string instead of a megabytes-long base64 argument. For local/private
files too large to inline, run the **local stdio server** (it mirrors `/mcp`): it reads on your
machine and calls this same API. Point it here with `OPENWARRANT_BASE_URL=https://www.stipple.sh`; setup is in
`mcp_server/README.md`.

## The result bundle

```json
{
  "schema_version": "0.1",
  "engine_version": "0.3.0",
  "warrant_id": "warrant_ac103b88b662afbf",
  "inspected_at": "2026-06-03T11:42:07Z",
  "mode": "standard",
  "risk_band": "low",
  "inspection_quality": "limited",
  "recommended_action": "review_before_action",
  "summary": "Inspection completed with risk band 'low', inspection quality 'limited', 1 warning signal(s), and 0 error signal(s).",
  "document": {
    "document_path": "payslip.pdf",
    "file_sha256": "ac103b88b662afbf...",
    "file_size_bytes": 48213,
    "file_extension": ".pdf",
    "mime_type": "application/pdf",
    "document_type": "unknown",
    "country_code": "unknown",
    "type_confidence": 0.0,
    "classification_source": "vlm"
  },
  "signals": [
    {
      "signal_id": "content.arithmetic_consistency",
      "title": "Arithmetic consistency",
      "status": "pass",
      "severity": 0.0,
      "confidence": 0.9,
      "axis": "risk",
      "summary": "Totals reconcile with their line items.",
      "evidence": []
    },
    {
      "signal_id": "classification.document_type",
      "title": "Document-type classification",
      "status": "warning",
      "severity": 0.0,
      "confidence": 0.5,
      "axis": "quality",
      "summary": "Document type could not be confidently determined; type-specific checks were not auto-run. Routing to review (coverage limit, not a risk finding).",
      "evidence": [
        { "kind": "classification", "message": "vlm", "location": null, "value": null }
      ]
    }
  ],
  "debug": {}
}
```

> **Schema notes:**
> - **`risk_band` ∈ `low | medium | high | insufficient | error`** — the authenticity-RISK
>   axis (forensic + content evidence only). `insufficient` = too little evidence to rate;
>   `error` = the inspection itself failed.
> - **`inspection_quality` ∈ `complete | limited | degraded`** — the COVERAGE axis (how
>   fully the document could be read). Orthogonal to risk. A scan/photo or skipped OCR/VLM
>   yields `limited`; a render/OCR/signal failure yields `degraded`.
> - **Each signal has an `axis` ∈ `risk | quality`.** A `quality` signal in `warning` (like
>   `classification.document_type` above) is a *coverage* note, **not** a tampering finding.
>   Never sum raw warnings across both axes — route on `recommended_action` and on
>   `risk`-axis signals.
> - **`recommended_action` ∈ `continue_workflow | review_before_action | escalate_review |
>   retry_or_escalate`** — the single field to branch your workflow on.
> - **`signals[].status` ∈ `pass | warning | error | skipped`.** `skipped` means the signal
>   did not run (e.g. VLM off, or document type not confirmed) — it is neither a pass nor a
>   finding.
> - **`severity` and `confidence` are 0–1 self-reported signal strengths**, not calibrated
>   probabilities. `type_confidence` likewise is the model's own self-report. Don't read them
>   as a fraud likelihood.
> - **`document.document_path` is the filename you sent.** The server's temp path is scrubbed
>   out; no local paths leak. `evidence` is kept deliberately compact and non-sensitive.

## Minimal Python example

```python
import hashlib, json, httpx

BASE = "https://www.stipple.sh"
doc = open("payslip.pdf", "rb").read()
sha = hashlib.sha256(doc).hexdigest()

# 1. Cache check — skip the paid inspection if this exact file was seen before.
hit = httpx.get(f"{BASE}/v1/warrants/check", params={"sha256": sha}).json()
if hit["cached"]:
    bundle = httpx.get(f"{BASE}/v1/warrants/{hit['warrant_id']}").json()
else:
    # 2. Inspect. Stream so the connection survives the multi-second VLM call.
    bundle = None
    with httpx.stream("POST", f"{BASE}/v1/warrants",
                      params={"stream": 1},
                      files={"file": ("payslip.pdf", doc)},
                      timeout=300) as s:
        event = None
        for line in s.iter_lines():
            if line.startswith("event:"):
                event = line.split(":", 1)[1].strip()
            elif line.startswith("data:") and event == "result":
                bundle = json.loads(line.split(":", 1)[1])
            elif line.startswith("data:") and event == "error":
                raise RuntimeError(json.loads(line.split(":", 1)[1])["detail"])

# Read the two axes separately, then branch on the recommended action.
print(bundle["risk_band"], bundle["inspection_quality"], bundle["recommended_action"])
for s in bundle["signals"]:
    if s["axis"] == "risk" and s["status"] in ("warning", "error"):
        print("RISK:", s["title"], "—", s["summary"])
```

## Rate limits

Enforced per caller — an API key when you present one, else a salted hash of your source IP
(falling back to `User-Agent`):

| Limit | Value |
|---|---|
| **Documents / day (anonymous, per IP)** | **20** |
| **Documents / day (with a free API key)** | **50** |
| Burst guard (anti-hammer) | up to 10 at once, refilling ~30/min |
| Max upload size | 25 MB |

The **daily quota is the headline limit** — over it returns `429` with `Retry-After` =
seconds until it resets at **00:00 UTC**. The burst guard catches sub-second hammering and
also returns `429` + `Retry-After`. Respect both.

**API keys (free, instant)** — get your own quota instead of sharing the per-IP one
(important for hosted agents, where many users share one egress IP):

```
curl -X POST https://www.stipple.sh/v1/keys -H "Content-Type: application/json" \
  -d "{\"email\": \"you@example.com\"}"
# -> { "api_key": "stp_...", "daily_limit": 50 }   (shown ONCE — store it)
```

Send it on every call as `Authorization: Bearer stp_...` (REST and MCP alike). Check your
metered usage any time: `GET https://www.stipple.sh/v1/usage` with the same header. A presented-but-invalid
key returns `401` (it never silently falls back to the anonymous quota).

## Constraints

- **Inputs**: a single PDF, or an image (`.png/.jpg/.jpeg/.webp/.bmp/.tif`). Other types
  are read as bytes but won't get the document-specific checks.
- **Images run in deep mode** — full visual forensics + VLM review — so they are slower than
  a born-digital PDF (which the standard text/structure checks cover at far lower cost).
  Use `?stream=1` for images so the connection doesn't time out.
- **URL intake**: `/v1/verify-references`, `/v1/detect-ai-text`, and all three MCP servers'
  tools accept a public `url` (SSRF-guarded fetch: public http(s) hosts only, size-capped).
  `/v1/warrants` itself does not (`501`) — fetch the bytes yourself and send `file`/`bytes_b64`,
  or call the `verify_document` MCP tool, which does take a `url`.
- **Privacy**: with the VLM on, the document is uploaded to a third-party model and the raw
  bytes are stored (subject to a retention window per the ToS). Only send documents you are
  authorised to share; prefer synthetic samples when testing.

## Etiquette

- **Cache-check first** (endpoint 2), or rely on the content-hash dedupe — don't burn the
  paid inspection re-submitting the same file.
- **Stream slow inspections** (`?stream=1`) rather than holding a blocking request open.
- **Don't retry** on `400`, `413`, or `501` — they won't change. On `429`, back off and
  honour `Retry-After`. On an `error` event, back off exponentially (transient failures
  happen).
- **Identify yourself** with a descriptive `User-Agent` header.
