> ## Documentation Index
> Fetch the complete documentation index at: https://docs.querying.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Rendering: copies of your pages that AI agents can read

> querying.ai renders each page you publish as a person sees it and writes the text of its images into their alt text. Your CDN keeps these copies and sends them to ChatGPT and other AI agents, while people keep getting your site.

AI agents read the HTML your server sends. They run no scripts, so prices, reviews and specs a script draws never
reach them, and they read the text inside an image only from its alt text. AI Rendering renders each page you
publish as a person sees it, removes the scripts and writes the text of each image into its alt text. Your CDN
downloads these copies and answers AI agents with them. People, search engines and every other request reach your
site as before, and querying.ai is never on the path of a request to your site.

[GEO Score](/research/geo-score.md) measures the gap: `rendering.score` is the share of a page a copy carries. Once your
CDN sends the copy, the agents that get it read that much.

## How it works

1. **Connect your site.** You get a site key once. It can only read your site's copies.
2. **Publish pages.** Send a page URL. querying.ai renders it from your site's country, keeps the copy, and renders the
   page again every `refreshDays` days (28 by default).
3. **Sync the copies.** Every 10 minutes your CDN reads the copy list with the site key, downloads new copies and drops
   the ones no longer listed.
4. **Answer AI agents.** When an AI agent sends a GET, your CDN answers with the stored copy. A page without a copy, an
   expired copy and any error go to your origin.

## Connect a site

```bash theme={"theme":"css-variables"}
curl -X POST https://api.querying.ai/v1/ai-rendering/sites \
  -H "Authorization: Bearer $QUERYING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "host": "www.example.com" }'
```

```json theme={"theme":"css-variables"}
{
  "success": true,
  "site": {
    "id": "5b0e9f52-6a3c-4f0e-8d0b-2c7e4a1f9d33",
    "host": "www.example.com",
    "refreshDays": 28,
    "country": "US",
    "keyPrefix": "rs_8f2c4a1b",
    "pages": 0,
    "createdAt": "2026-10-23T01:00:00.000Z",
    "updatedAt": "2026-10-23T01:00:00.000Z"
  },
  "key": "rs_8f2c4a1b…"
}
```

Keep the `key`: only this response has it, and your CDN sends it. `POST /v1/ai-rendering/sites/{id}/key` gives a new
one and stops the old one at once.

| Field | Required | Description |
| - | - | - |
| `host` | Yes | The public host name your CDN serves, such as `www.example.com`. Connect each host once, up to 20 per account. |
| `refreshDays` | No | Days between renders of a published page, `1`–`28`, default `28`. A copy's file expires after 30 days. |
| `country` | No | Where pages are rendered from: `US` (default) or `KR`, as GEO Score's reader country. Pick the country your customers browse from. |

Change them later with `PATCH /v1/ai-rendering/sites/{id}`.

## Publish a page

```bash theme={"theme":"css-variables"}
curl -X POST https://api.querying.ai/v1/ai-rendering/sites/5b0e9f52-6a3c-4f0e-8d0b-2c7e4a1f9d33/pages \
  -H "Authorization: Bearer $QUERYING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://www.example.com/products/sunscreen" }'
```

The page is being rendered:

```json theme={"theme":"css-variables"}
{
  "success": true,
  "page": {
    "id": "a3d1c9e0-7b52-4e1f-9c84-0f6b2d5e7a10",
    "url": "https://www.example.com/products/sunscreen",
    "path": "/products/sunscreen",
    "status": "rendering",
    "copy": null,
    "taskId": null,
    "renderedAt": null,
    "expiresAt": null,
    "publishedAt": "2026-10-23T01:00:00.000Z",
    "refreshAt": "2026-10-23T07:00:00.000Z",
    "refreshTaskId": "7c4e2a90-1d3b-4f6a-8e25-9b0c1f3d4e57"
  },
  "taskId": "7c4e2a90-1d3b-4f6a-8e25-9b0c1f3d4e57"
}
```

A render takes about 30 seconds. Follow it with `GET /v1/async/task/{taskId}`, or read the page's `status` from
`GET /v1/ai-rendering/sites/{id}`:

* `ready`: the copy is in your copy list. `copy` is its HTML file.
* `rendering`: the first copy is being made.
* `failed`: the first render failed. The page is rendered again at `refreshAt`.
* `expired`: the copy expired and left the list until the next render.

A site holds up to 1,000 pages. Sending a published page again renders it now. A page is its path and query, without
tracking parameters such as `utm_*` and `gclid`. `DELETE /v1/ai-rendering/sites/{id}/pages/{pageId}` takes a page
down.

To publish the copy a [GEO Score](/research/geo-score.md) made without rendering again, send its task id:
`{ "taskId": "0b6c1f7e-…" }`. It must be a completed GEO Score of your account, scored from the site's country.

## Set up your CDN

Save this as `ai-rendering.mjs` beside your CDN code. `syncCopies` brings a store in line with your copy list, and
`copyFor` answers an AI agent's GET from it:

```js theme={"theme":"css-variables"}
export const COPIES_URL = "https://api.querying.ai/v1/ai-rendering/copies";

export const AGENTS = [
	"chatgpt-user",
	"codex",
	"claude-user",
	"claude-code",
	"perplexity-user",
	"gemini-deep-research",
	"duckassistbot",
	"amzn-user",
	"mistralai-user",
	"oai-searchbot",
];

export const DOWNLOADS_PER_SYNC = 40;

const EXPIRY_MARGIN_MS = 10 * 60_000;

const TRACKING_PARAM =
	/^(?:utm_[a-z0-9_]+|gclid|gbraid|wbraid|dclid|fbclid|msclkid|twclid|ttclid|srsltid|mc_cid|mc_eid|_ga|_gl)$/i;

export function pagePath(url) {
	const kept = [...url.searchParams].filter(([name]) => !TRACKING_PARAM.test(name));
	return (url.pathname || "/") + (kept.length ? "?" + new URLSearchParams(kept).toString() : "");
}

export async function copyKey(host, path) {
	const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(host.toLowerCase() + path));
	return "copy:" + [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

export function agentRequest(request, agents = AGENTS) {
	if (request.method !== "GET") return false;
	const userAgent = (request.headers.get("user-agent") ?? "").toLowerCase();
	return agents.some((token) => userAgent.includes(token));
}

export async function syncCopies(options) {
	const {
		siteKey,
		store,
		copiesUrl = COPIES_URL,
		downloads = DOWNLOADS_PER_SYNC,
		fetch: send = fetch,
		now = Date.now,
	} = options;
	if (!siteKey) throw new Error("AI Rendering: no site key");
	const answer = await send(copiesUrl, { headers: { "X-Querying-Site-Key": siteKey } });
	if (!answer.ok) throw new Error(`AI Rendering: the copy list answered ${answer.status}`);
	const { host, copies } = await answer.json();
	const stored = await store.list();
	const listed = new Set();
	const result = { listed: copies.length, downloaded: 0, failed: 0, waiting: 0, removed: 0 };
	for (const copy of copies) {
		const key = await copyKey(host, copy.path);
		listed.add(key);
		if (stored.get(key) === copy.renderedAt || Date.parse(copy.expiresAt) - now() < EXPIRY_MARGIN_MS) continue;
		if (result.downloaded + result.failed >= downloads) {
			result.waiting++;
			continue;
		}
		try {
			const file = await send(copy.copy);
			if (!file.ok) throw new Error(String(file.status));
			await store.put(key, await file.arrayBuffer(), { renderedAt: copy.renderedAt, expiresAt: copy.expiresAt });
			result.downloaded++;
		} catch {
			result.failed++;
		}
	}
	for (const key of stored.keys())
		if (!listed.has(key)) {
			await store.delete(key);
			result.removed++;
		}
	return result;
}

export async function copyFor(request, store, agents = AGENTS) {
	if (!agentRequest(request, agents)) return null;
	try {
		const url = new URL(request.url);
		const copy = await store.get(await copyKey(url.hostname, pagePath(url)));
		if (!copy || !(Date.parse(copy.expiresAt) > Date.now())) return null;
		return new Response(copy.body, {
			status: 200,
			headers: {
				"Content-Type": "text/html; charset=utf-8",
				"Cache-Control": "private, no-store",
				Vary: "User-Agent",
				"X-Querying-Rendering": "HIT",
				"X-Querying-Rendered-At": copy.renderedAt,
			},
		});
	} catch {
		return null;
	}
}
```

**Cloudflare Worker**

The Worker keeps copies in Workers KV and syncs them every 10 minutes on a cron trigger. Each copy expires in KV
with its file.

```bash theme={"theme":"css-variables"}
npx wrangler kv namespace create AI_RENDERING
npx wrangler secret put QUERYING_SITE_KEY
```

`wrangler.toml`:

```toml theme={"theme":"css-variables"}
name = "ai-rendering"
main = "cloudflare-worker.mjs"
compatibility_date = "2026-10-01"
routes = [{ pattern = "www.example.com/*", zone_name = "example.com" }]
kv_namespaces = [{ binding = "AI_RENDERING", id = "<the namespace id>" }]

[triggers]
crons = ["*/10 * * * *"]
```

`cloudflare-worker.mjs`:

```js theme={"theme":"css-variables"}
import { copyFor, syncCopies } from "./ai-rendering.mjs";

export function kvStore(kv) {
	return {
		async get(key) {
			const { value, metadata } = await kv.getWithMetadata(key, "arrayBuffer");
			return value && metadata ? { body: value, ...metadata } : null;
		},
		async list() {
			const stored = new Map();
			let cursor;
			do {
				const page = await kv.list({ prefix: "copy:", cursor });
				for (const key of page.keys) stored.set(key.name, key.metadata?.renderedAt);
				cursor = page.list_complete ? undefined : page.cursor;
			} while (cursor);
			return stored;
		},
		put: (key, body, meta) =>
			kv.put(key, body, { expiration: Math.floor(Date.parse(meta.expiresAt) / 1000), metadata: meta }),
		delete: (key) => kv.delete(key),
	};
}

export default {
	async fetch(request, env) {
		return (await copyFor(request, kvStore(env.AI_RENDERING))) ?? fetch(request);
	},
	async scheduled(_controller, env, ctx) {
		ctx.waitUntil(syncCopies({ siteKey: env.QUERYING_SITE_KEY, store: kvStore(env.AI_RENDERING) }));
	},
};
```

On the Workers Free plan, KV takes 1,000 writes a day, so the first sync of a large site can take more than a day.

**Other CDNs and servers**

Use the same module with a store on your platform: an object with `get(key)`, `list()`, `put(key, body, meta)`
and `delete(key)`. `list()` returns each stored key with the `renderedAt` it was stored with. Run `syncCopies` on
a schedule and `copyFor` on each page request. If your CDN caches by URL alone, add the agent to the cache key so
a person never gets a copy.

Check a published page as ChatGPT would open it, once the next sync has run. The copy carries
`X-Querying-Rendering: HIT`:

```bash theme={"theme":"css-variables"}
curl -sI -A "Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/bot)" https://www.example.com/products/sunscreen
```

## The copy list

Your CDN reads `GET https://api.querying.ai/v1/ai-rendering/copies` with the site key in `X-Querying-Site-Key`:

```json theme={"theme":"css-variables"}
{
  "success": true,
  "host": "www.example.com",
  "copies": [
    {
      "path": "/products/sunscreen",
      "url": "https://www.example.com/products/sunscreen",
      "copy": "https://api.querying.ai/v1/evidence/1792717200-9f3c2a7d1e4b.html",
      "renderedAt": "2026-10-23T01:00:31.000Z",
      "expiresAt": "2026-11-22T01:00:00.000Z"
    }
  ]
}
```

It lists every published page whose copy has not expired. `copy` downloads without a key. `renderedAt` changes when
the page is rendered again, so download a copy only when its `renderedAt` is new. Drop the stored copies of pages
the list no longer has. The list and the downloads are free.

## Agents

`AGENTS` in the module chooses who gets the copy, by a token anywhere in the User-Agent, case-insensitively.

* **Default:** the agents a person sends to a page while asking a question (`chatgpt-user`, `codex`, `claude-user`,
  `claude-code`, `perplexity-user`, `gemini-deep-research`, `duckassistbot`, `amzn-user`, `mistralai-user`) and
  ChatGPT search's crawler, `oai-searchbot`. ChatGPT search answers from the page its crawler keeps, which is what
  GEO Score's `score` measures. Its index changes after OpenAI crawls the page again.
* **Add if you want:** `claude-searchbot` and `perplexitybot`.
* **Leave out:** training crawlers (GPTBot, ClaudeBot) and web search crawlers (Googlebot, Bingbot). Googlebot runs
  scripts itself.

## Stay within search engine rules

Sending bots different content from people is cloaking. Google does not treat a page rendered for bots as cloaking
as long as the content is similar ([Dynamic rendering](https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering)).
OpenAI has published no cloaking policy, so hold the copy to Google's rule:

* The copy is the page a person's browser drew. querying.ai removes scripts and adds only the text inside images,
  in their alt text, the place an image's text belongs.
* It goes out at the page's own URL.
* It stays current. querying.ai renders it again every `refreshDays`, and the module stops sending a copy when it
  expires.
* If you edit a copy, only remove or correct: fix a misread character, drop a pop-up. Do not add claims, prices or
  links people do not see.

## What the copy is

The copy is the HTML a person's browser drew after the page was scrolled to the end: the text scripts loaded, each
frame's page in place, every script removed except structured data (JSON-LD), and the text of each large image added
to its alt text. Links and image sources stay absolute, so the copy reads like the page. It is the page at its last
render: a price or stock change reaches agents at the next render, at most `refreshDays` later, plus the time until
your CDN syncs. Image text is copied as it was read, so a misread character goes out as read.

## Billing

A render costs 5 credits, plus 5 for each image whose text is read and 2 for each image your account read in the
last 90 days (the same file, read the same way), at most 12 images. Submitting holds 65 credits and completion
charges what the render used. A failed render is not charged. A refresh is charged the same, to the site's account,
and an account without the credits waits an hour before the page is tried again. Connecting a site, publishing a
GEO Score's copy, the copy list and downloading copies are free. Your CDN sends the copies, so agent traffic costs
nothing at querying.ai. See [Credits and limits](/concepts/credits-and-limits.md).

## Errors

| Status | Code | Cause |
| - | - | - |
| `401` | `UNAUTHORIZED` | The copy list got no site key, a replaced key, or the key of a removed site. |
| `402` | `INSUFFICIENT_CREDITS` | The balance cannot cover the 65-credit hold of a render. |
| `403` | `KEY_SCOPE_DENIED` | The API key is limited to engines and `GEO_SCORE` is not one of them. |
| `404` | `NOT_FOUND` | No site, page or GEO Score of your account with that id. |
| `409` | `SITE_EXISTS`, `SITE_LIMIT`, `PAGE_LIMIT`, `TASK_NOT_COMPLETED` | The host is connected already, the account has 20 sites, the site has 1,000 pages, or the GEO Score has not completed. |
| `410` | `COPY_EXPIRED` | The GEO Score's copy expired. Publish the page by URL. |
| `422` | `VALIDATION_ERROR`, `NO_RENDERED_COPY` | A field is not valid, the URL is on another host, the GEO Score was scored from another country, or it made no copy. |
| `429`, `503` | | Plan concurrency or the render queue is full. Retry after `Retry-After`. |

The endpoints are in the [API reference](/api-reference/ai-rendering/create-site.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.