The wire contract
An adapter talks to two endpoints and nothing else. This page is what it sends, what it gets back, and what it has to prove before it can call itself an adapter. It is written so a second implementation in another language can be built from this page alone, because one is coming and because a contract only one codebase can satisfy is not a contract.
Every payload carries v. The server accepts every version it has ever shipped, forever. Versions are frozen and never removed.
That is not generosity. An adapter sitting inside a WordPress install, on a site somebody manages but does not own, cannot be force-upgraded. If a wire version could ever be retired, then shipping server code would be able to break a live customer site, and no amount of care at deploy time makes that safe.
- 01An adapter ignores fields it does not recognise, in any response.
- 02The server never requires a field an older adapter does not send.
- 03Deprecation is advisory. A bundle may carry deprecated: true, which the dashboard surfaces so you upgrade on your own schedule.
POST /v1/events. One payload per window, at most once: there is no retry queue anywhere in this contract, because an unbounded one spread across hundreds of live sites is the failure nothing else in the design could survive.
{
"v": 1,
"key": "pk_live_...",
"batchId": "9f2c1e4a-...",
"adapter": { "name": "node", "version": "0.1.0" },
"window": {
"start": "2026-08-24T22:00:00.000Z",
"end": "2026-08-24T22:01:00.000Z"
},
"counters": [
{ "path": "/products/widget",
"uaClass": "browser", "status": 200, "count": 42 }
],
"botEvents": [
{ "path": "/products/widget", "status": 200, "bytes": 18422,
"uaToken": "<agent>", "ip": "203.0.113.7",
"at": "2026-08-24T22:00:31.004Z" }
],
"dropped": { "counters": 0, "botEvents": 0 }
}Two channels, because only one of them may carry an address. counters aggregates human and unknown traffic to a path, a class and a status, where uaClass is browser or other. It carries no address, ever, under any configuration. botEvents is per request and does carry one, because each has to be checked against published ranges before it is believed. That check happens once, on ingest, and the address is discarded rather than stored.
window is reported, not assumed, and batchId makes delivery idempotent. Both exist so an adapter with no long-lived process can comply: PHP-FPM handles each request in a separate process, so cross-request in-memory buffering does not exist there and a retry is sometimes the only option it has. The server dedupes on batchId and tolerates overlapping windows and short batches.
dropped is not optional. When a buffer overflows, the event is discarded and the count increments. A silent drop would be a lie in the coverage number the whole product is sold on, so an adapter that cannot report its drops does not conform.
GET /v1/bundle?key=&v=1, with If-None-Match, every fifteen minutes, jittered. A 304 does nothing at all.
{
"v": 1,
"etag": "b3:7c1f...",
"mode": "full",
"deprecated": false,
"crawlerTokens": ["<agent>", "<agent>"],
"manifest": [
{ "path": "/llms.txt",
"contentType": "text/plain; charset=utf-8",
"sha256": "e3b0c442...", "bytes": 812 }
],
"files": { "/llms.txt": { "inline": "IyBFeGFtcGxlLi4u" } },
"signature": "ed25519:..."
}inline is base64. A file may instead carry { "url": "https://..." }, which is how large artifacts move to object storage without a contract change. Either way it is checked against the sha256 in the signed manifest before anything is written.
mode is off, record or full, and off keeps polling. That is the remote kill switch, and it exists because we cannot push a fix to an installed adapter: it is the only way to stop a misbehaving one across every install at once, and the only reason stopping it is reversible.
Application is atomic. Write to a temporary directory, fsync, swap it in, keep the previous bundle, roll back on any failure. A partially written bundle is never visible to a request, and if the API is unreachable for a week the last good bundle keeps serving correctly.
Files are stored on disk by content hash, never by manifest path. A manifest path is attacker-influenced text that can pass validation and still traverse: /../../x does start with a slash. Content addressing makes escaping the bundle directory impossible rather than merely filtered.
The signature is ed25519 over the canonical JSON of exactly six fields:
["v", "etag", "mode", "deprecated", "crawlerTokens", "manifest"]files is excluded on purpose rather than by oversight: artifact bytes may move to object storage later, and a field whose shape can still change must not be locked inside a signed contract. Nothing is lost by it, because every file is already covered by the sha256 of the manifest row that names it, and that row is signed.
A manifest row is rebuilt before it is checked. A version 1 adapter reconstructs each row from exactly path, contentType, sha256 and bytes, drops everything else, and canonicalises what it rebuilt. Two consequences, both normative: a server may add unknown fields to a row and a version 1 adapter must ignore them, and the server must keep computing the signature over that four-field set for as long as any version 1 adapter is installed. Sign the row you actually sent, with a fifth field in it, and every version 1 adapter rejects the bundle.
Canonical JSON is byte-for-byte, not close enough. Keys sorted by UTF-8 byte order; array order preserved, because it means something; no whitespace anywhere; a key whose value is undefined dropped rather than written as null; integers only, inside the range a double represents exactly, because float-to-string rendering is not portable.
Strings escape only ", \ and the C0 controls. This is the part that will bite a second implementation: PHP's json_encode escapes / by default and mangles non-ASCII unless told not to. Every manifest[].path in a signed bundle contains a slash, so getting this wrong fails every signature, silently, on every bundle rather than in some edge case.
// PHP, to produce the same bytes:
json_encode($s, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)The signature is not optional. These files are served from the customer's own domain, so an unsigned bundle is a content-injection path into a live site. A bundle that fails verification is rejected whole and the previous one keeps serving.
One bound that looks cosmetic and is not: a crawler token shorter than three characters is rejected. An empty token matches every request and a lone slash matches virtually every user-agent, because product/version is the universal convention. The crawler channel is the one that carries an address, so a degenerate token would ship every visitor's IP.
An adapter serves only paths present in the signed manifest, so it cannot shadow a route the host application owns. The match is exact: no case folding, no trailing-slash tolerance, no prefix matching.
Collision detection is not the adapter's job. The manifest is generated server-side, and paths the probe crawl found already in use are excluded before it is signed. The prefix is baked in there too: two sides computing paths independently is two implementations that can disagree, and a disagreement here serves a 404 on a customer's domain.
Every alternate is publicly reachable, byte-identical for every requester, and carries a canonical link back to the original. The serving path never sees the user-agent when it decides what to answer, which means serving crawlers different content is not a policy an adapter follows. It is a thing this contract cannot express.
Nothing is injected into the customer's pages. A link rel="alternate" tag on the original page is a discovery aid, not the safety mechanism: what makes the design safe is that alternates are reachable, linked, canonically pointed and identical for everyone, and none of that needs an existing page modified. Discovery is the generated sitemap and the index instead. Injection would mean rewriting HTML inside the response path, which trades the one governing principle for a benefit available another way.
Every bound the contract froze. These are not tuning knobs: each is the difference between a bounded adapter and one that can grow without limit inside somebody else's production process, so they are asserted in tests rather than left to drift.
| Bound | Value | What it bounds |
|---|---|---|
maxCounters | 2,000 | Distinct counter keys held per flush window. |
maxBotEvents | 500 | Individual crawler events held per flush window. |
flushIntervalMs | 60,000 | One minute, or sooner at high-water. |
flushTimeoutMs | 2,000 | A flush past this is aborted, and the window is dropped rather than queued. |
syncIntervalMs | 900,000 | Fifteen minutes between bundle polls. |
syncJitterRatio | 0.2 | How far that interval is spread, so a hundred installs do not align on one second. |
maxBundleBytes | 33,554,432 | 32 MiB. Binary, not decimal: the ambiguity in "32 MB" is a boundary two implementations can disagree about. |
maxPathBytes | 512 | A recorded path is cut to this, on a UTF-8 boundary so a multi-byte character is never split. |
maxManifestEntries | 10,000 | Manifest rows accepted in one bundle. Bounds the work that necessarily runs before a signature can reject it. |
maxCrawlerTokens | 200 | Crawler tokens accepted in one bundle. Same reasoning, and every token is matched against every request. |
An adapter may configure a smaller buffer than the bound. It may not configure a larger one: the server truncates at exactly these numbers and counts the remainder as loss, so the only effect of asking for more is more resident memory and a worse coverage figure.
One suite, and every adapter in every language passes it. That is the only thing keeping two implementations honest against one contract, so the scenarios ship as data rather than as tests: a PHP harness cannot import a TypeScript test file, but it can enumerate this list and assert the same ids.
What each scenario asserts is left to the host language, because a shared assertion would either be too vague to fail or too Node-shaped to satisfy. The enforceable cross-language contract is the id set.
- api-unreachable
- A flush to an unreachable API is dropped, not queued(Events)
- api-slow
- A flush past the timeout is aborted and dropped(Events)
- bundle-malformed
- A malformed bundle leaves the previous one serving(Bundle)
- bundle-bad-signature
- A bundle with a bad signature is rejected whole(Bundle)
- bundle-hash-mismatch
- A file that fails its manifest hash rejects the bundle(Bundle)
- bundle-oversized
- A bundle past the size cap is rejected before it is read(Bundle)
- counter-overflow
- Counter overflow drops and reports the drop(Limits)
- bot-overflow
- Bot event overflow drops and reports the drop(Limits)
- concurrent-flush
- Two concurrent flushes produce one payload(Events)
- concurrent-sync
- Two concurrent polls produce one fetch and one apply(Bundle)
- path-collision
- Only manifest paths are served; everything else falls through(Serving)
- mode-transitions
- full to record to off stops serving then recording; off to full resumes both(Bundle)
- version-forward-compat
- Unknown response fields are ignored, not rejected(The rule)
- memory-ceiling
- A sustained crawl storm stays inside the declared bounds(Limits)
- no-ip-for-humans
- Human traffic carries no IP anywhere in the payload(Events)
A scenario removed from that list is a promise withdrawn from the contract. If you are building an adapter, ask us for the suite and the machine-readable copy of this list.
Installing rather than implementing? The adapter guide is the shorter page.