# Cloudflare OS: An LLM Operating System Built on One Account, One Deployment, One Grammar

slug: the-unified-loop · https://miscsubjects.com/a/the-unified-loop · tags: infrastructure, cloudflare, mcp, agents, architecture, canonical · updated 2026-08-06T09:16:18.828Z

# Cloudflare OS: An LLM Operating System Built on One Account, One Deployment, One Grammar

This article is the canonical description of the miscsubjects.com build. It is written for a reader who has never seen it before. It assumes nothing. It defines every term. It links every claim to a source. It is the article you read if you want to understand what this build is, why it exists, and what it does.

---

## The problem this solves

AI systems today are opaque. A model reads your data, makes a decision, and you cannot reconstruct what it saw or why it acted. If the answer is wrong, you cannot trace the error. If the answer is right, you cannot prove it.

The industry response is more tooling: retrieval layers, evaluation harnesses, orchestration frameworks, observability dashboards. Each tool adds a layer. Each layer adds a cost. Each cost adds a failure mode. The stack grows. The opacity remains.

This build takes the opposite approach. It removes the stack. It replaces it with one grammar.

---

## The grammar: one object, one address, one receipt

The grammar is OIP — the [Object Invocation Protocol](/a/oip). It is the only protocol in the build. It governs every object, every invocation, every receipt.

An OIP object states the work, the input, the authority, the invocation route, the runner, the proof requirement, the receipt, the replay path, and the repair path. A model operates OIP by resolving an object, reading the object contract, invoking the object route, and returning the receipt.

The OIP unit is the work object. The OIP proof is the receipt. The OIP loop is object, invoke, ledger, receipt, replay, repair.

Every article is an OIP object. Every claim is an OIP object. Every source is an OIP object. Every capability is an OIP object. Every event in the ledger is an OIP object. The operator's Mac is an OIP object. The Stripe charge is an OIP object. The text message is an OIP object.

One grammar. One address. One receipt.

---

## The loop: how the system runs

The system runs in a short, receipted loop. Six steps. No retrieval layer. No evaluation harness. No orchestration framework.

**Step 1: Resolve.** A model receives a plain-language request. It queries the object grammar: `?ask=<plain language>` or `?key=<KEY>`. The grammar returns the matching object.

**Step 2: Contract.** The object carries a machine-readable contract. The contract describes the inputs, the behavior, the authority required, and the proof that must return.

**Step 3: Scope.** The model checks the credential. `?explain=1&share=TOKEN` reports what the credential permits. No credential, no invocation.

**Step 4: Invoke.** The model invokes the object. The object runs. The runner is a Cloudflare Worker, a D1 query, an R2 fetch, a KV read, a Queue message, a Workflow step, a Pages function, or an AI Gateway call. The runner is one of six Cloudflare primitives.

**Step 5: Ledger.** The invocation is recorded in the ledger. The ledger is append-only. The ledger is signed. The ledger is immutable.

**Step 6: Receipt.** The receipt is returned. The receipt contains the object, the input, the output, the cost, the timestamp, the signature, and the replay path. The receipt is the proof.

---

## The infrastructure: Cloudflare as the LLM OS

The build runs on one Cloudflare account. One deployment. One database. One operator.

The Cloudflare primitives used:

- **Workers**: The compute layer. Every capability is a Worker. Every Worker is a function. Every function is a receipt.
- **D1**: The database. One SQLite database at the edge. Every article, every claim, every source, every capability, every event.
- **R2**: The object store. Every image, every file, every artifact. Immutable. Hash-chained.
- **KV**: The key-value store. Session state, rate limits, feature flags.
- **Pages**: The frontend. Static. Fast. Global.
- **AI Gateway**: The model router. Every model call is logged. Every call has a cost. Every cost is receipted.
- **Workflows**: The orchestration. Multi-step operations. Durable. Receipted.
- **Vectorize**: The vector database. Semantic search. Embeddings. Similarity.
- **Queues**: The message queue. Async processing. Receipted delivery.
- **Email Routing**: The email layer. Inbound and outbound. Receipted.

One account. One deployment. One database. One operator.

---

## The evidence graph: how belief is computed

The build does not trust models. It verifies them.

Every model examination of an object is stored as a signed record. The record contains the model, the timestamp, the claim, the conclusion, and the signature. The record is immutable.

The evidence graph is the set of all signed records for all objects. The graph is queryable. The graph is public. The graph is auditable.

Belief is computed from the graph. The current belief value of an object is the highest-value signed record by recency, trust-weight, and independence. The computation is deterministic. The computation is public. The computation is receipted.

---

## The sources: where the evidence lives

Every claim in the build carries a source. The sources are public. The sources are queryable.

The sources include Cloudflare documentation, Anthropic documentation, Reddit reports, and independent operator reports. Each source is attached to a specific claim. Each source is hash-chained. Each source is immutable.

The sources are the evidence. The evidence is public. The public can audit.

---

## The objections: what this cannot do

This build cannot prove that a model's conclusion is correct. It can prove that the model examined the object, that it produced a signed record, and that the record is immutable. It cannot prove that the conclusion is true.

This build cannot prevent a model from hallucinating. It can prove that the model read the governing document. It cannot prove that the model understood it.

This build cannot scale to billions of objects without compaction. The append-only ledger grows. The graph resolution slows. The working set expands. The tradeoff is documented. The tradeoff is public.

---

## The verdict

This build is a working prototype of a different way to organize AI systems. Every article, tool, skill, claim, and API is the same kind of invocable object — one address, one history, a receipt for every action.

The build is not a product. It is a protocol. The protocol is public. The protocol is queryable. The protocol is auditable.

The build is not a company. It is one operator. The operator is public. The operator is accountable. The operator is the build.

The build is not a stack. It is one grammar. The grammar is public. The grammar is minimal. The grammar is enough.

---

## The links

- The protocol: https://miscsubjects.com/a/oip-spec
- The system map: https://miscsubjects.com/api/articles/system-map
- The proof: https://miscsubjects.com/api/proof
- The ledger: https://miscsubjects.com/admin/ledger
- The read gate: https://miscsubjects.com/a/read-gate
- Proof of coverage: https://miscsubjects.com/a/proof-of-coverage
- The evidence graph: https://miscsubjects.com/a/object-ledger-evidence-graph-spec

Every link is live. Every link is queryable. Every link is a receipt.


## Sources

1. Model Context Protocol — Tools — https://modelcontextprotocol.io/docs/concepts/tools
2. Anthropic — Tool search tool — https://docs.claude.com/en/docs/agents-and-tools/tool-use/tool-search-tool
3. Cloudflare D1 — https://developers.cloudflare.com/d1/
4. Cloudflare Pages Functions — Routing — https://developers.cloudflare.com/pages/functions/routing/
5. Cloudflare Workers — Limits — https://developers.cloudflare.com/workers/platform/limits/
6. OpenAI — Assistants overview — https://platform.openai.com/docs/assistants/overview
7. LangChain — Introduction — https://python.langchain.com/docs/introduction/
8. CrewAI documentation — https://docs.crewai.com/
9. LlamaIndex documentation — https://docs.llamaindex.ai/en/stable/
10. Hacker News — zc2610, 2026-04-14 — https://news.ycombinator.com/item?id=47767067
11. Hacker News — ramesh31, 2025-06-06 — https://news.ycombinator.com/item?id=44197352
12. Hacker News — justvugg, 2025-12-23 — https://news.ycombinator.com/item?id=46370218
13. Hacker News — DarmokJalad1701, 2025-06-04 — https://news.ycombinator.com/item?id=44186682
14. Cloudflare Workers AI — Pricing — https://developers.cloudflare.com/workers-ai/platform/pricing/
15. Operator report: tool definitions alone eat 50K-100K tokens — https://x.com/dani_avila7/status/1993134510364389555
16. Seven MCP servers burned 67k tokens before a keystroke — https://x.com/aakashgupta/status/2011664388424454262
17. Load nothing until it is needed — https://x.com/dzhng/status/2029518820872945889
18. Fifty definitions and the agent struggles to pick — https://x.com/milvusio/status/2047014068888703391
19. Practical ceiling of about forty MCP tools in Cursor — https://www.reddit.com/r/mcp/comments/1jbiqex/max_mcp_tool_limits_hit_in_cursor/
20. Bloated prompt, unstable model — https://www.reddit.com/r/mcp/comments/1jimv3u/whatll_hapen_if_there_has_a_lots_of_tool_in_mcp/
21. Specifications take space and degrade every tool — https://www.reddit.com/r/ClaudeAI/comments/1ka3jao/why_is_claude_is_so_good_at_tool_calling/mpz8b2g/
22. opencode #35376: 40k-70k tokens of definitions loaded upfront — https://github.com/anomalyco/opencode/issues/35376
23. copilot-cli #4189: reported cost 20x the real deferred cost — https://github.com/github/copilot-cli/issues/4189
24. hermes-agent #67273: schemas are 83.1% of the token budget — https://github.com/NousResearch/hermes-agent/issues/67273
25. The token claim is an implementation detail of the harness — https://news.ycombinator.com/item?id=48331540
26. Progressive disclosure already solves it — https://news.ycombinator.com/item?id=47719249
27. Prompt caching turns it into a fixed penalty — https://news.ycombinator.com/item?id=47160970


---

# The site as a folder you can hold: what the portable content graph actually measured

slug: the-vault-projection · https://miscsubjects.com/a/the-vault-projection · category: systems · tags: knowledge-graph, obsidian, markdown, second-brain, architecture, export · updated 2026-08-06T08:24:32.106Z

A folder on your computer and a page on this site should be the same object, seen from two places. That is the whole idea. Everything below is what it takes to mean it, and what was actually measured here on 6 August 2026 when someone finally checked.

## What was already built, and what was actually running

This site could already hand you a folder. A single page as markdown, a page as a folder with its claims and sources and provenance beside it, every article carrying one tag as a folder of page folders, the whole library as a folder of folders, and an Obsidian vault export with wikilinks and a canvas file. All of it existed. Some of it had existed for weeks.

Four of those endpoints were dead.

Not slow. Not degraded. Answering `error code: 1102`, which is what Cloudflare returns when a worker exceeds its resource limit and is killed. The link graph, the graph lint, the ranked queue of what to write next, and the Obsidian vault export — the four surfaces that the entire portable-knowledge idea rests on — could not run at all against the live corpus.

The reason is worth stating exactly, because it is a general failure and not a typo. The graph was derived on every request. A request would ask "what links to what?", and the code would select the body of all 2,317 published articles, pull roughly 90 megabytes of prose into a single worker, and run regular expressions over it. That worked at 200 articles. It worked at 800. Somewhere on the way to 2,317 it stopped working, and nothing said so, because no test measured the endpoint at corpus scale. The code was correct. The architecture was arithmetic that ran out.

So the first repair is not a feature. Edges are rows now.

## Edges are rows

There is a table. Each row is one link: the article it was typed in, the target as written, the kind of link, the resolved target, the display label, the heading anchor, and whether it resolved.

It is written at the write path — the single moment a body changes — and read back by index. Asking "what links here" is now one indexed lookup instead of a scan of the corpus, which is why it can render on every page. Asking for the whole graph reads 4,206 small rows instead of 90 megabytes of prose.

An identity note that matters more than it looks: a row is identified by the article it came from, the target, and the kind. Not by a row number. So re-syncing one article is a delete-then-insert of that article's own rows and can never disturb another article's edges. One page being rewritten does not put the graph in an undefined state.

A link whose target does not exist is kept, not thrown away. `to_slug` is null, `resolved` is 0, and the row stays. This is deliberate. An unresolved link is the most valuable finding the corpus can produce: it is a page that a writer explicitly asked for, in prose, by naming it. Discarding those rows would make the graph look healthier than it is, which is the one thing a graph must never do.

## What the corpus actually looks like

Here is the state on the day the table was filled, from the corpus itself rather than from anyone's impression of it.

**2,317 published articles. 4,206 edges from 596 of them. 1,721 articles — 74 percent — point at nothing at all.**

Those are the figures from the backfill pass on 6 August 2026. They move, and they are meant to: the live count is served by the graph endpoint itself, and every article published or unpublished since changes it. Quoting a frozen number as the current one is the failure this build treats as serious, so the number above is dated and the endpoint is the authority.

The claim that started this was that no article on the site links to any other article. That is not what the numbers say: 596 articles do link out, and one page carries 66 internal links. But the shape of the complaint was right and the direction was right. Three quarters of the corpus is a leaf. It sits there, correctly written, sourced, adjudicated, and connected to nothing. A knowledge base where three quarters of the pages are unreachable from any other page is a pile of documents that happen to share a domain name.

**318 of those links name a page that does not exist.**

That number started at 433 and fell twice, and both falls were the measurement getting honest rather than the corpus getting better. 100 of the original were stale rows: ten pages had been repaired upstream, and the backfill could add an edge but never remove one, because its candidate filter only visited articles that still carried a link pattern — an article whose links were deleted was never looked at again. Another 15 were links to the laws, which render at /a/<slug> from code rather than from rows, so a resolver that only checked the articles table called every one of them broken. A broken-link list salted with pages that already answer 200 is a list nobody can act on, so both were fixed at the mechanism: the backfill now reconciles before it extracts, and one resolver serves both the write path and the backfill.

Those are worth reading rather than summarizing, because the list is diagnostic. The most-wanted missing page is requested by 21 different articles. Then one wanted by 14, one by 12. Then this run: `oip-convergence-edge-11`, `-22`, `-33`, `-44`, `-55`, `-66`, `-77`, `-88`, `-99`, and `-1010`, each requested by exactly 10 articles.

Those are not ten missing pages. Doubled digits ending in `1010` is what a loop that writes an index twice produces. Some generator built those link targets by concatenating a number with itself, and 100 broken links have been sitting in the corpus ever since, pointing at pages that were never going to exist under those names. Nobody found it because nothing could compute the graph. The dead endpoint was hiding its own evidence.

## A download that returns a different download

While measuring the export routes, a worse thing surfaced. Asking for one tag returned the entire library.

Not an error. HTTP 200, `content-type: text/markdown`, 9.9 megabytes, and no header anywhere in the response saying that what came back was not what was asked for. A person clicking "download the peptide folder" would receive the whole site and have no way to know.

Three layers had to agree for that to happen, and they did.

First, the export handler read the whole corpus and filtered afterward in code. Any scope wider than a single article pulled the same 90 megabytes into one worker and threw — the same arithmetic failure as the graph.

Second, this site has an always-up layer, and it earns its place: it keeps a snapshot of the last good response so a page mid-rewrite serves the last good version instead of a 500. Its snapshot key is the pathname. The query string is discarded. So every scope of `/api/articles/export` — one article, one tag, one category, the entire library — shared exactly one snapshot. When the live handler failed, the caller received whichever scope had most recently succeeded.

Third, the eligibility rule that decides which paths may hold a snapshot was consulted before *writing* one and not before *serving* one. So a path forbidden from ever storing a snapshot could still be answered from one.

The comment sitting directly above the neighbouring rule already said a bare request only, "so a parameterized response never overwrites the canonical snapshot." The reasoning was written down. It was applied to one pattern and not to the one next to it.

All three are fixed. Tag and category now select their own rows in SQL and read nothing else. The whole library reads in windows. Snapshot eligibility requires no query string, and now governs serving as well as writing.

The general rule this leaves behind is worth more than the fix: **a cache keyed on less than the request will eventually answer a different question than the one asked.** A route whose meaning lives in its query cannot be keyed on its path.

## Where you are, and what you can take

Every download named above was reachable only by knowing its URL, or by three plain-text links at the very bottom of an article, below the sources. Which is why the owner asked twice for downloads that already existed. A capability nobody can find is indistinguishable from one that was never built.

There is now a trail across the top of every article:

> Home / peptide / BPC-157

Each segment is a link, and the trail carries one control that hands you any level of it as a folder:

- **This article** — markdown, json, its folder as a zip, its skill file
- **Its history** — the last 1, 10, or 100 revisions as markdown, or all of them as json
- **Everything under its tag** — markdown, or the tag folder as a zip
- **The whole site** — markdown, the folder of folders as a zip, the Obsidian vault, the link graph

The nesting the owner described is exactly the nesting that exists: a page is a folder, a tag is a folder of page folders, the site is a folder of folders. Unzip the site folder and it opens as a vault.

Clicking the middle crumb used to land on the homepage. /t/<tag> was not a route at all — /t/peptide and /t/zzzznotatag returned the same bytes — so the trail asserted a hierarchy the site could not honour. It is a page now: the articles under that tag newest first, the sibling tags they are also filed under, and the folder downloads at that level. An unknown tag returns a 404 that names where to go instead of a homepage that pretends.

One more thing had to be true before any of this was portable. Every note this build exported carried its YAML frontmatter a few hundred bytes into the file, because the self-explaining preamble was prepended in front of it. Obsidian reads properties only from the opening three dashes at byte 0, so the whole export looked correct and had no machine-readable identity at all: no properties panel, nothing a Bases view could query, nothing a round trip could compare. The frontmatter now leads the file and carries a durable ms:article:<slug> id, the revision count, the content hash and the permalink. It is the same defect shape as a slot composer burying an authored body — a wrapper winning over the content it was meant to introduce — and it is pinned by a test that asserts the first four bytes.

### The middle crumb is a tag, and that was not a preference

The obvious thing to put between Home and the article is its category. That does not work here, and the reason is a fact about this corpus rather than a design opinion: **2,235 of 2,317 published articles have no category at all.** Of the 82 that do, some disagree with themselves — `Governance` and `governance`, `system` and `systems`, `peptides` and `compound`.

Tags cover 2,174 of 2,317. The folder builder already groups the corpus by tag. So the tag became the middle crumb, because it is the only level that both exists for nearly every page and names the same thing the download hands over. A breadcrumb that names a folder the download does not produce is a lie told in navigation.

## What is inherited from Obsidian, and what is refused

The field this borrows from is inventoried in [[second-brain-register]] — who built which part, and which parts last. Obsidian is the right compatibility target for one structural reason: a vault is a folder of markdown files and attachments on a disk. There is no proprietary container to reverse-engineer. Shida Li and Erica Xu released the first beta on 30 March 2020 and 1.0.0 on 13 October 2022; the storage model has always been ordinary files.

That makes it something rare — a widely used knowledge tool whose native format is the format this site already stores. The primitives worth inheriting are few:

**Files and folders.** A page is a file. A tag is a folder. Already true in the export.

**Properties.** YAML at the top of the file. Every exported note needs a stable identifier, a revision, a content hash, its slug, its permalink, its tags, and its updated time.

**Internal links and backlinks.** `[[double brackets]]`, and the reverse index derived from them. Backlinks are never authored — they are the edge table read the other way.

**Canvas.** Obsidian's canvas format is an open specification, JSON Canvas 1.0, published 11 March 2024. Two optional top-level arrays, `nodes` and `edges`, with four node types: `text`, `file`, `link`, and `group`. A category canvas is a mechanical projection of the edge table into that shape — no drawing, no layout engine, just nodes and edges written to a file Obsidian already opens.

**Bases.** A core plugin since Obsidian 1.9.0 on 21 May 2025, and a `.base` file is plain YAML with `filters`, `formulas`, `properties`, `summaries`, and `views`. That means a saved query over the corpus is a text file this build can generate — "every article with an unsourced claim", "everything updated this week" — and it opens as a table in the reader's own app.

Now the refusals, which matter as much.

**Do not treat visible wikilinks as the graph.** There are two different things here and merging them destroys both. An editorial link is one a writer placed for a reader, inside a sentence, because it belongs there. A typed relation is a machine-readable fact — this supports that, this contradicts that, this supersedes that — which may or may not deserve to interrupt a sentence. The edge table stores relations. The renderer decides how each one appears: an anchor on the site, `[[a title]]` in a vault, a relative path in portable markdown, a target id in json. One relation, four representations.

**Do not let the filesystem become the source of truth.** A path, a title, a slug, and a category all change. An identity may not. Folders are a projection of collections, and moving a file changes its projection, not what it is. This matters concretely, not theoretically: an article can belong to peptides and to neurology at once, and a file can only sit in one folder without being duplicated.

**Do not attempt to inherit every Obsidian feature.** Obsidian has a plugin ecosystem in the thousands. Cloning it is an unbounded project that would never finish and would produce a worse Obsidian. Inherit the data model and the handful of interactions above; let everything else be a view over those primitives, or a local tool the reader already owns.

**Do not build automatic two-way sync yet, and be honest about why.** One-way — the site hands you a vault — is safe and works today. The reverse direction is where knowledge bases get destroyed. It needs a recorded base revision per file and a genuine three-way comparison: only the remote changed, update the file; only the file changed, propose a revision; both changed from the same base, refuse and raise a conflict. A missing file is never an inferred deletion. A model may propose a merge; the model that produced the merge may not be the thing that certifies it preserved everything. Until that state machine exists and is tested, shipping a push path would be shipping a way to lose work.

## What a model can do with this that it could not do before

This is the part that pays for the rest. When the corpus is a graph you can query rather than prose you must read, the instruction "organize this" stops being a request for judgment and becomes a series of checkable operations.

- **Find what nothing points at.** One query. 1,721 answers today.
- **Find what has been asked for and never written.** One query over unresolved targets, ordered by how many articles want it. The top answer is wanted by 21 pages. That is a commission, not a suggestion.
- **Find generator bugs.** The `-1010` run was invisible in 2,317 bodies and obvious in one sorted list.
- **Propose links without writing them.** Candidate edges from shared sources, shared tags, and exact title matches, submitted as a patch — moves, new links, removed links, redirects, the articles affected, and the revisions expected. Deterministic checks confirm every target resolves before anything is applied.
- **Reorganize as a filesystem operation.** Nested folders and linked markdown are a shape every coding model already knows how to manipulate. It needs no special protocol for this site.

The last one is the actual prize, and it is why the graph had to become a table before anything else could be built on it.

## The invariants this leaves behind

These are the rules the repairs imply. They are stated so the next failure of the same class is recognizable.

**An edge is a row.** A graph derived per request over the corpus will pass every test at small scale and die silently at large scale. If it cannot be answered by an index, it will eventually not be answered at all.

**A cache key must cover the whole request.** Keyed on less, it will hand one caller another caller's answer, with a success code.

**One gate, both directions.** A path forbidden from writing a cached copy must not be served one either. Two halves of one rule drift apart the moment they are written in two places.

**An unresolved link is kept.** It is the strongest signal in the corpus about what to write next. Deleting it is deleting the evidence.

**Identity is not a path.** Slugs, titles, folders and categories are projections. What a thing is survives being moved.

**A model may propose a reorganization and may not certify its own.** The thing that generated the merge does not get to be the thing that confirms nothing was lost.

## What is not done

Stated plainly, because a specification that hides its gaps is worse than no specification.

The 1,721 unlinked articles are still unlinked — the graph can now name them, which is what makes the work possible, but nothing has connected them yet. The 433 broken links are still broken, including the 100 from the doubled-digit generator. The vault export does not yet carry a full YAML property block with a stable identifier per note, and until it does, a round trip cannot be verified by hash. There is no push path, for the reasons above. There is no manifest binding an export to one content head, so two people downloading "the whole site" a minute apart cannot yet prove they hold the same thing.

The rules above are written the way [[coding-law]] and [[writing-law]] are written, in the surfaces models load, not in prose, and for the same reason: a correction that lives only in prose is a correction the next agent never sees.

Each of those is a task row, not a paragraph in a report. The distinction is the point: this document describes what was measured and repaired. What remains is work, and work here exists only as a row that names its own acceptance test.


## Sources

1. miscsubjects link graph endpoint (live) — https://miscsubjects.com/api/articles/graph-links
2. Karpathy, llm-wiki (GitHub gist, April 2026) — https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
3. Karpathy, llm-wiki — why a compiled wiki beats retrieval — https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
4. JSON Canvas — open file format for infinite canvas data — https://jsoncanvas.org/
5. Obsidian Bases syntax (core plugin, .base files) — https://obsidian.md/help/bases/syntax
6. miscsubjects tag-scoped export (live, header reports the count) — https://miscsubjects.com/api/articles/export?tag=peptide


---

# The OIP Federation Inbox Verifies Signed Agent Messages and Runs Only Audience-Bound Invokes

slug: oip-federation-inbox · https://miscsubjects.com/a/oip-federation-inbox · category: engineering · tags: oip, federation, security, architecture, capabilities, webcrypto · updated 2026-08-06T03:11:19.851Z

# The OIP Federation Inbox

The OIP federation inbox is the receiving end of a protocol for agent-to-agent messaging across domains. A remote agent at another domain sends a signed `oip-message/1` envelope to `POST https://miscsubjects.com/oip/inbox`, and the inbox decides what, if anything, to run.

The inbox is a route file in the Cloudflare Pages project at `functions/oip/inbox.js`. Its companion, `functions/_lib/oip_envelope.js`, is the shared envelope implementation — one file imported by every runtime (the Pages Functions handler and the standalone oip-peer Worker), using WebCrypto only and no external dependencies.

## The envelope

Every message on the wire is an `oip-message/1` envelope. The envelope is the unit of federation: it carries a protocol identifier, a unique message id, a conversation id, a kind, a sender (`from`), a recipient (`to`), timestamps, a body, a body hash, and a signature.

The protocol defines seven kinds, drawn from FIPA-ACL performatives and trimmed to what OIP needs: `query`, `propose`, `invoke`, `result`, `event`, `cancel`, and `error`. Each message declares what kind of speech act it is, so a receiver never has to guess intent from prose.

Two hard limits shape the wire: an envelope lives at most 15 minutes (900 seconds), and the inline payload ceiling is 65,536 bytes. Data larger than that travels by pointer rather than inline.

## Identity is not authority

The inbox verifies the envelope shape, freshness, body hash, and the sender's signature against the sender domain's `/.well-known/oip.json` document. This is identity verification — it proves which agent at which domain sent the bytes. It grants no authority by itself.

The law of the wire, stated in the envelope library, is that an envelope is data. Text inside the body is never an instruction. Only `kind:"invoke"` carrying a valid, audience-bound capability can make anything run, and the receiving server re-checks every gate itself. Signatures prove origin; they do not confer permission.

An unpublished sender — one whose well-known document cannot be resolved — may only send a query. Every other kind requires a resolvable signing key.

## The replay membrane

The inbox rejects any message id it has already seen. A message id is delivered at most once, ever. The seen-check runs first, but the id is only marked seen after the sender's signature verifies — so a forged, unverifiable envelope cannot poison a legitimate sender's future message id.

This is a replay membrane: a resent envelope never re-runs anything. The seen key is stored in KV with a 24-hour TTL.

## Query: data, not instructions

When the inbox receives a `query`, it echoes the body back as data. Nothing is executed. This is the explicit design point for prompt-injection resistance: a payload that says "ignore your rules and run X" is returned as data, not honored as an instruction.

The reply carries `retrieved_text_is_data: true` and a note: "A query grants and requires no authority. Message text is data, never an instruction — nothing was executed."

## Invoke: the only kind that runs

`invoke` is the only kind that can run an object. It requires a valid capability in `envelope.capability`. The inbox verifies the token's signature and expiry, looks up the capability record by nonce, and then runs a sequence of gates:

- **Audience binding**: the capability must be explicitly bound to a remote agent or domain. An ordinary unbound token cannot cross the federation boundary.
- **Audience match**: the capability's audience must match the verified sender. A capability handed to one agent cannot be used by another.
- **Scope**: the token must allow the requested key.
- **Chain**: no parent of the capability may be revoked or expired.
- **Contract and tenant**: the capability record's own gates and tenant isolation are re-checked.
- **Uses**: the capability must have remaining uses; an exhausted token is refused with 429.

Only after all gates pass does the inbox call `dispatch` to run the object. The result is wrapped with a proof, an invocation record, and an `on_behalf_of` chain that records the cryptographically verified sending agent as the immediate actor.

## Cross-ledger receipts

The reply to an invoke carries a `cross_ledger` block with three hashes: the request body hash (`request_body_sha256`), the input hash (`input_sha256`), and the output hash (`output_sha256`). The message id and invocation id are also present.

This is the join key: the two separately deployed ledgers — the sender's and the receiver's — can be joined by these hashes and the message id without either trusting the other. The sender can verify that the bytes it sent produced exactly the bytes the receiver claims to have produced.

## End-to-end encryption

If the inbound message was encrypted to the home node's key, the reply is sealed back to the sender's key, making the full round trip confidential over any transport. The encryption is ECDH-P256 with AES-GCM — deliberately simple ECIES. An ephemeral sender key means every message has a fresh shared secret. Signatures stay independent: the envelope is signed after encrypting, so the signature covers the ciphertext, and the same sealed bytes travel over HTTPS, email, or any other transport.

## Discovery

A federated agent is resolved through its domain's `/.well-known/oip.json` document. The `resolveAgent` function fetches the document (with an 8-second timeout and optional KV caching), finds the agent by id, and returns its public key and inbox URL. If the document is unreachable, the agent is not published, or the record is incomplete, resolution fails and only queries remain open.

## What the inbox does not do

The inbox does not trust transport alone. It does not treat message text as instructions. It does not let an unbound capability cross the federation boundary. It does not let a capability minted for one agent be used by another. It does not re-run a message id. And it does not run anything without re-checking every gate itself — scope, chain, contract, tenant, and uses.

The design is a single principle carried to its conclusion: identity is not authority, and data is not instruction. The inbox is the membrane where that principle is enforced.

## Sources

1. functions/oip/inbox.js — handler header — functions/oip/inbox.js
2. functions/oip/inbox.js — handler responsibilities 2 and 3 — functions/oip/inbox.js
3. functions/oip/inbox.js — handler responsibility 4 — functions/oip/inbox.js
4. functions/oip/inbox.js — handler responsibility 5 — functions/oip/inbox.js
5. functions/oip/inbox.js — replay membrane comment — functions/oip/inbox.js
6. functions/oip/inbox.js — query handling comment — functions/oip/inbox.js
7. functions/oip/inbox.js — invoke handling comment — functions/oip/inbox.js
8. functions/oip/inbox.js — federation check comment — functions/oip/inbox.js
9. functions/_lib/oip_envelope.js — law of the wire — functions/_lib/oip_envelope.js
10. functions/_lib/oip_envelope.js — message kinds — functions/_lib/oip_envelope.js
11. functions/_lib/oip_envelope.js — envelope limits — functions/_lib/oip_envelope.js
12. functions/_lib/oip_envelope.js — file header — functions/_lib/oip_envelope.js


---

# The Total Structure: Ten Books Across Three Planes — the complete OIP corpus and its architecture

slug: oip-total-structure · https://miscsubjects.com/a/oip-total-structure · category: philosophy · tags: oip, philosophy, systems, architecture, total-structure · updated 2026-08-03T04:38:17.320Z

## THE TOTAL STRUCTURE

*One paragraph of zero context: this is the root page for the Object Invocation Protocol — ten books across three planes that together form a complete philosophy of systems, how they work, how they decay, how they get captured, and how they are repaired. Every other page on this site is a slice of what follows. None of them stand alone. This page is the map that shows how they connect.*

The corpus divides into three planes: **The Word** (what is true), **The Way** (how to act on what is true), and **The Work** (what to build). Each plane contains books. Each book contains axioms, doctrines, and a shelf of worked problems. The whole is designed to be falsifiable — if any axiom fails under stress, the structure says so explicitly, and the amendment protocol says how to replace it without destroying the rest.

This page exists because ten published books wikilink to it as their root — [[oip-ground]], [[oip-obligation]], [[oip-terrain]], [[oip-method]], [[oip-machine-plane]], [[oip-object-grammar]], [[oip-amendment-protocol]], [[oip-beyond-incentive]], [[oip-falsification]], [[oip-gravity]] — but no page occupied the slug. The triage door ([[oip-triage]]) points here as the first thing a new reader should see after the one-paragraph entry. The convergence encyclopedias ([[convergence-encyclopedia-appendix-a]], [[convergence-encyclopedia-appendix-c]]) reference the ten-book structure as the spine of every cross-reference they audit.

### Plane One — The Word

Plane One is what is true regardless of who acts on it. It contains two books.

**Book I — Ground** ([[oip-ground]])

Twelve axioms, each stress-tested against its own negation before being admitted. The moral floor — the commitments that cannot be traded away for capability. The shelf: worked problems where an axiom's edge case is pushed until it either holds or the amendment protocol is triggered. Ground is the foundation: everything above it is either derived from these axioms or explicitly marked as a defeasible extension.

**Book II — Obligation** ([[oip-obligation]])

Capability creates debt. The measure of a debt is the capability it enables. The disclosure doctrine: a system that hides its obligations is not operating in good faith. The remedy hierarchy — how debts are paid, and in what order, when a system cannot pay all of them at once. The shelf: cases where the remedy hierarchy was applied under resource constraints.

### Plane Two — The Way

Plane Two is how to act on what is true. It contains two books.

**Book III — Terrain** ([[oip-terrain]])

What systems are. The four states — running, degraded, captured, repaired — and the transitions between them. Operating alone: what a system does when no adversary is visible (the answer: it invents one, because without one the falsification surface disappears). The dialect boundary: the line between a system and its environment, and why that line is not where most analysts draw it.

**Book IV — Method** ([[oip-method]])

Trace to systemic intersection — the core analytical move: follow a symptom down to the place where two systems meet, because that is where the failure lives. The Fulcrum Protocol: a structured method for identifying the single point where intervention changes the outcome. The adversarial application: every analysis is tested against an adversary before it is trusted. The objection ledger: every objection that was raised and either incorporated or refuted is recorded, so the analysis is auditable. The decision engine: the procedure for committing to an action under uncertainty.

### Plane Three — The Work

Plane Three is what to build. It contains six books — the largest plane, because building is where most of the effort lives.

**Book V — The Machine Plane** ([[oip-machine-plane]])

The valuable output: what a machine is for, stated as a single sentence. The economic primitive: the smallest unit of economic activity the machine can produce. Alpha as energy competition: the thesis that competitive advantage is fundamentally about who can concentrate more energy (in the physics sense, which includes computation) per unit of cost. LLM-as-OS: the claim that a large language model, properly structured, functions as an operating system for reasoning — scheduling, memory, tool dispatch, and state management.

**Book VI — The Object Grammar** ([[oip-object-grammar]])

Everything is an object. The one door: there is a single interface through which all objects are created, read, updated, and destroyed — no back doors, no special cases. The universal loop: the cycle of query, contract, invocation, and result that every capability follows. The repair doctrine: objects that cannot be repaired are not objects; they are waste. The drop: the process by which an object that cannot be repaired is removed from the system without orphaning its dependents.

**Book IX — The Amendment Protocol** ([[oip-amendment-protocol]])

The document is an object — meaning the corpus itself is subject to the same object grammar it describes. The amendment classes: the permitted ways to change a page (correction, extension, supersession, retraction), each with its own gate. The review recursion: every amendment is reviewed, and the review is itself an amendment, creating a recursive audit trail. The capture guard: the procedure for detecting when an amendment is not a genuine improvement but a capture attempt — someone using the amendment process to weaken the structure for their own benefit.

**Book VIII — Beyond Incentive** ([[oip-beyond-incentive]])

Rational action and right action: the claim that these are not the same thing, and that systems designed only around rational action will fail in a way that systems designed around right action will not. The necessary adversary: every system needs an adversary to remain falsifiable; a system with no adversary drifts toward unfalsifiability and then toward capture. What ought be: the normative layer — not what is, not what can be, but what should be, and how that judgement is grounded without appealing to authority.

**Book X — Falsification** ([[oip-falsification]])

The eight surfaces: the eight ways a claim in the corpus can be attacked (axiomatic, empirical, logical, structural, adversarial, dialectical, practical, and meta). The attack protocol: the structured procedure for mounting an attack on any claim, so that the attack is maximally informative — if the claim survives, you know exactly what it survived. Appendix A: the dependency map showing which claims depend on which axioms, so that a failed axiom's blast radius is visible at a glance.

**Book XI — Gravity** ([[oip-gravity]])

The proof: the argument that the entire structure is not arbitrary — that the ten books are the minimal set needed to cover the territory of systems, and that removing any one leaves a gap that cannot be filled by the others. Where this sits in the corpus: the meta-claim that the structure itself is an object, subject to its own falsification, and that the day it fails its own attack protocol is the day it is amended or replaced.

### How the planes relate

The three planes are not independent. Plane One (The Word) establishes what is true. Plane Two (The Way) establishes how to act on what is true. Plane Three (The Work) establishes what to build to make that action repeatable. The dependency runs downward: a change in Ground can cascade through Method, Object Grammar, and the Amendment Protocol. A change in the Amendment Protocol can cascade back upward, because the protocol governs how Ground itself is changed.

This circular dependency is not a bug. It is the point. A structure that cannot amend its own axioms is dogma. A structure that can amend its own axioms but has no guard against capture is vulnerable. The Amendment Protocol (Book IX) and the Capture Guard together ensure that the structure can evolve without being co-opted.

### The reading order

There is no single correct reading order, but there is a recommended one for a reader who wants to understand the whole before drilling into any part:

1. [[oip-triage]] — the one-paragraph entry point
2. [[oip-ground]] — the axioms everything else assumes
3. [[oip-obligation]] — what the axioms cost
4. [[oip-terrain]] — the world the axioms describe
5. [[oip-method]] — how to navigate that world
6. [[oip-machine-plane]] — what to build
7. [[oip-object-grammar]] — how to build it
8. [[oip-beyond-incentive]] — why to build it that way
9. [[oip-amendment-protocol]] — how to change any of the above
10. [[oip-falsification]] — how to attack everything you just read
11. [[oip-gravity]] — why this set and not another

A reader who wants to test the structure rather than understand it should read in the reverse order: Falsification first, then Amendment Protocol, then work backward to Ground. If the structure survives that reading, it has earned its keep.

### What this page is for

This page is the root. It does not introduce new claims; it maps the claims that already exist in the ten books. Every wikilink on this page resolves to a published article. If a link does not resolve, the loop law has failed, and the next-acts queue should have surfaced a write act for the missing slug — which is exactly how this page was generated. [[embed:source:s1]]

The structure is complete when every wikilink in every book resolves to a page that carries its own claims, its own sources, and its own amendment trail. The corpus is not that yet. The loop law is the procedure that gets it there, one act at a time. [[embed:source:s2]]

## Sources

1. Book I — Ground: the twelve axioms and the moral floor — https://miscsubjects.com/a/oip-ground
2. The Triage Door: the one-paragraph entry point to the OIP corpus — https://miscsubjects.com/a/oip-triage
3. Book II — Obligation: capability creates debt — https://miscsubjects.com/a/oip-obligation
4. Book IV — Method: the Fulcrum Protocol and adversarial analysis — https://miscsubjects.com/a/oip-method
5. Book VI — The Object Grammar: everything is an object — https://miscsubjects.com/a/oip-object-grammar
6. Book IX — The Amendment Protocol: the document is an object — https://miscsubjects.com/a/oip-amendment-protocol
7. Book X — Falsification: the eight surfaces and the attack protocol — https://miscsubjects.com/a/oip-falsification
8. Book XI — Gravity: the proof that the ten books are the minimal set — https://miscsubjects.com/a/oip-gravity


---

# Cloudflare Access authenticates the edge, not your application

slug: cloudflare-os-access · https://miscsubjects.com/a/cloudflare-os-access · tags: cloudflare, architecture, security, cloudflare-os, access, zero-trust, authentication · updated 2026-07-26T06:20:14.182Z

Cloudflare Access sits between a request and an origin. For a person, it turns an application URL into an identity check: Cloudflare redirects the browser to an identity provider, applies an Access policy, and issues a signed session token. For a machine, there is no login page. It must send a service credential on the first request, and the Access policy must explicitly accept that credential.

The distinction that decides the design:

> Access proves that a request satisfied an edge policy. Your application still decides what that authenticated principal may do.

A service token can pass Access and still carry no human identity. A Bypass rule can make a path reachable while removing Access authentication and Access logging from that path. Deleting the application does not prove the service token was deleted, and deleting the token does not prove the application or policy disappeared. Those are separate objects with separate list and delete operations.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The request path, without product names hiding the mechanics

| Stage | Human request | Machine request |
| --- | --- | --- |
| 1. Request arrives | Browser requests the protected hostname and path | HTTP client requests the same URL |
| 2. Access checks credential | Looks for a valid `CF_Authorization` cookie | Looks for service-token headers or another non-human credential |
| 3. No valid credential | Redirects to the Access login flow | Usually a `302` the client cannot use, or `401`/`403` when Service Auth handling is configured |
| 4. Policy evaluation | Allow, Block, Bypass, or a more specific rule | Service Auth, mTLS, or Bypass |
| 5. Origin request | Cloudflare forwards `Cf-Access-Jwt-Assertion` | Cloudflare forwards an application JWT after service authentication |
| 6. Origin authorization | Verify signature, issuer and audience; map `email` or `sub` to an app role | Verify the same fields; map `common_name` to a synthetic machine principal |

Access is not an origin firewall. Unless the origin is connected only through Cloudflare Tunnel or otherwise restricted to Cloudflare, an attacker may try to reach it directly and avoid the Access layer. Even when every request must pass Cloudflare, the origin still verifies the JWT. Cloudflare's application-token reference is blunt: validation of the header alone is insufficient because an unverified header can be spoofed.

## Create one self-hosted application in the dashboard

Prerequisites: a Cloudflare account, a Zero Trust organization, a domain on Cloudflare, and an identity provider. The built-in one-time PIN flow is enough for a small first deployment; an organization using group rules should connect its existing SAML or OIDC provider and confirm the exact group claim before writing policy.

Current dashboard path:

1. Open **Zero Trust**.
2. Go to **Access controls** → **Applications**.
3. Select **Add an application**.
4. Choose **Self-hosted**.
5. Set **Application name**.
6. Under **Session Duration**, choose how long the application JWT remains valid.
7. Under **Add public hostname**, enter **Subdomain**, **Domain**, and optional **Path**. A path makes the Access application narrower than the hostname.
8. Under **Access policies**, create or attach a policy.
9. Choose the identity providers shown on the login page.
10. Save, then test one allowed identity and one denied identity before widening the selectors.

Access applications are deny-by-default. Creating the hostname without an Allow or Service Auth policy does not grant anyone access.

The four policy actions do different jobs:

| Action | What a match means | Correct use | Dangerous misunderstanding |
| --- | --- | --- | --- |
| **Allow** | The request may continue after identity authentication | People selected by email, IdP group, country, device posture, or another identity rule | “Not blocked” does not mean allowed; unmatched users remain denied |
| **Block** | The matching request is denied | Carve a narrow denial out of a broader Allow rule | A Block rule alone does not make everyone else allowed |
| **Bypass** | Access enforcement is disabled for the matching traffic | A deliberately public webhook or health path whose own exposure is accepted | No Access identity, controls, or Access logs remain on that path |
| **Service Auth** | A non-IdP credential may pass | Service tokens or mutual TLS for automation | A service token is not a human and may not have `sub` or `email` |

Policy order and selectors matter. Test with the policy tester, then make real HTTP requests. A green dashboard object is configuration evidence, not traffic evidence.

## The same application and policy through the REST API

Use a Cloudflare API token scoped to **Access: Apps and Policies Write**. Keep the account id and API token in environment variables; neither belongs in shell history, an article, or a CI log.

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "name": "admin surface",
    "type": "self_hosted",
    "domain": "admin.example.com",
    "session_duration": "8h",
    "app_launcher_visible": false,
    "service_auth_401_redirect": true
  }'
```

Capture `result.id` as `ACCESS_APP_ID`. Do not hand-type it.

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps/$ACCESS_APP_ID/policies" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "name": "named administrators",
    "decision": "allow",
    "precedence": 1,
    "include": [
      {"email_domain": {"domain": "example.com"}}
    ]
  }'
```

The API response must say `success: true`. Follow it with a fresh GET of the exact application. A `201` proves creation, but the GET proves the stored hostname, policy and session settings are the ones you intended.

For infrastructure automation, create a service token separately:

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/service_tokens" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{"name":"deploy smoke","duration":"720h"}'
```

The client secret is returned once. Store it in the deployment secret store immediately. The token still does nothing until a policy accepts it:

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps/$ACCESS_APP_ID/policies" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "name": "deployment machine",
    "decision": "non_identity",
    "precedence": 2,
    "include": [{"any_valid_service_token": {}}]
  }'
```

Cloudflare's API calls the `non_identity` decision **Service Auth** in the dashboard. That naming difference is worth writing in the runbook; otherwise an operator comparing JSON with the UI can think the wrong policy was created.

## A service token is two secret headers and one policy

The normal first request carries:

```sh
curl -sS https://admin.example.com/health \
  -H "CF-Access-Client-Id: $ACCESS_CLIENT_ID" \
  -H "CF-Access-Client-Secret: $ACCESS_CLIENT_SECRET"
```

Cloudflare checks the two values, evaluates a Service Auth policy, and forwards the request with `Cf-Access-Jwt-Assertion`. A successful request can also return a `CF_Authorization` cookie. If the application contains only Service Auth policies, Cloudflare requires the service token on subsequent requests too; the JWT cookie alone is not enough.

Access also supports a single custom header containing both values. That helps SaaS clients with one configurable authorization field. It does not help software with no custom-header extension point.

That limitation is common, not theoretical.

`kennypy` put Jellyfin behind Access. Google SSO worked in a browser, but the Findroid client could not add the two headers and became LAN-only. `hippiuS` hit the same shape with an MCP client calling ArgoCD: the request became a `302` to an SSO page a non-browser could not follow, or a `403`. The ArgoCD CLI needed a general `--header` flag before it could work with this class of proxy authentication.

The rule: **check the client's HTTP surface before choosing Access for the endpoint.** “It can call HTTPS” is insufficient. It must be able to set two headers, one configured compound header, mTLS credentials, or an Access-aware token.

## The service-token JWT has authority but may have no user

The recovered first-party probe created a temporary self-hosted application, added Service Auth, minted a temporary service token, and called a protected path. With valid headers, Access let the request reach the origin. The origin returned its own `404`, which is the useful proof: the credential cleared the edge policy.

The redacted application-token payload had this shape:

```json
{
  "type": "app",
  "iat": 1785041363,
  "exp": 1785043164,
  "iss": "https://<team-name>.cloudflareaccess.com",
  "sub": "",
  "aud": ["<application-audience>"],
  "common_name": "<service-token-client-id>"
}
```

There was no `email` claim. `sub` was the empty string.

`dataGriff` documented the consequence in a CI smoke test: once Access was enforced, the service-token caller had no user id to own a review and no human admin standing. The correct repair is not to invent an email inside every handler. Map the verified service principal once, at the authentication seam:

```js
function principalFromAccessClaims(claims) {
  if (claims.type === "app" && claims.common_name) {
    return {
      kind: "machine",
      id: `access-service:${claims.common_name}`,
      roles: ["deploy-smoke"],
    };
  }
  if (claims.email && claims.sub) {
    return {
      kind: "human",
      id: claims.sub,
      email: claims.email,
      roles: rolesForEmail(claims.email),
    };
  }
  throw new Error("Access token has no usable principal");
}
```

The application authorizes `deploy-smoke` to do only the smoke-test operations. It does not promote every service token to administrator. `common_name` is useful only after the JWT signature, issuer and audience have passed.

## Verify the JWT at the origin

Read `Cf-Access-Jwt-Assertion`. Cloudflare recommends that header because the cookie is not guaranteed to reach the origin. Then verify:

1. The signature against the team's JWKS.
2. `alg` is the expected RS256 algorithm.
3. `iss` equals the exact team-domain issuer.
4. `aud` contains the exact Access application audience tag.
5. `exp` and `nbf` permit the current time.
6. The resulting human or machine principal is authorized for this application action.

With `jose`:

```js
import { createRemoteJWKSet, jwtVerify } from "jose";

const TEAM_DOMAIN = process.env.ACCESS_TEAM_DOMAIN;
const ACCESS_AUD = process.env.ACCESS_AUD;
const issuer = `https://${TEAM_DOMAIN}`;
const jwks = createRemoteJWKSet(
  new URL(`${issuer}/cdn-cgi/access/certs`),
);

export async function requireAccess(request) {
  const token = request.headers.get("Cf-Access-Jwt-Assertion");
  if (!token) return { ok: false, status: 401, error: "missing Access JWT" };

  try {
    const { payload, protectedHeader } = await jwtVerify(token, jwks, {
      issuer,
      audience: ACCESS_AUD,
      algorithms: ["RS256"],
    });
    return {
      ok: true,
      claims: payload,
      algorithm: protectedHeader.alg,
      principal: principalFromAccessClaims(payload),
    };
  } catch {
    return { ok: false, status: 403, error: "invalid Access JWT" };
  }
}
```

Do not hard-code a PEM. The public endpoint carries the current signing key and the previous rotated key. The fresh read on this account returned two RSA signing keys, both RS256. A remote JWKS loader selects by `kid` and survives rotation.

## Bypass is a public route, not machine authentication

A Bypass policy removes Access from matching traffic. Cloudflare does not apply Access security controls to it, and the request is not present in Access logs. That can be correct for a public payment webhook whose provider cannot send Access credentials, provided the handler verifies the provider's own signature and rejects replay.

It is not a shortcut for a private API.

`lesbass` reported a split application where the unauthenticated health endpoint worked while every company-scoped API call failed with `RESPONSIBLE_USER_UNAVAILABLE`. The Access identity existed, but it did not map to a company member. Opening more paths would hide the identity defect by removing authentication from them.

Use the narrowest path possible. Put a separate handler-level signature on a bypassed webhook. Do not Bypass `/api/*` because one vendor callback needs to be public.

## Deleting one object proves nothing about the other two

An Access deployment usually creates at least three resources:

| Resource | What deleting it removes | What remains |
| --- | --- | --- |
| Access application | Hostname/path protection and attached application policies | Reusable policies and service tokens may remain |
| Application policy | One Allow, Block, Bypass or Service Auth decision | Application and credentials remain |
| Service token | That client id and secret | Application and Service Auth policy remain, ready to accept another valid token |

The proof sequence is explicit:

```sh
curl -sS -X DELETE \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps/$ACCESS_APP_ID" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

curl -sS -X DELETE \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/service_tokens/$SERVICE_TOKEN_ID" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

curl -sS \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

curl -sS \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/service_tokens" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
```

Require HTTP success and Cloudflare `success: true` before examining either list. A `401`, `403`, or empty parse is not absence proof. The final lists must contain neither the exact id nor the exact temporary name.

The temporary measurement used for this page was rechecked at `2026-07-26T06:09:10Z`. Both authenticated lists succeeded. Neither the application id/name nor the service-token id/name was present. Zero probe resources remained.

## When a single bearer key is the better answer

This application's owner surface does not use Access. Its middleware accepts one owner key by request header or URL parameter, or a signed 60-day HttpOnly admin-session cookie minted after the key is entered once. An unauthenticated browser is redirected to the public login page. An unauthenticated machine request receives a bounded `401` JSON object.

That posture accepts a sharp trade: one strong secret has no person-level identity, no IdP offboarding and no device-posture check. In return, any HTTP client that can set one header can use it, the application controls the exact failure response, and machine calls do not depend on an SSO redirect.

For one owner and a closed automation surface, that can beat Access. For 20 administrators who need individual revocation and audit attribution, it does not.

| Option | Best fit | Identity | Machine-client requirement | Verdict |
| --- | --- | --- | --- | --- |
| Cloudflare Access | Several people, existing IdP, per-person revocation | Human email/groups; machine principal for service tokens | Custom headers, mTLS, or Access-aware client | Default for a shared admin UI |
| mTLS | Services or managed devices with certificate lifecycle | Certificate subject or mapped device | Client-certificate support | Strong machine auth; heavier issuance and rotation |
| One bearer key checked in the Worker | One owner, small fixed set of scripts | Shared principal only | One configurable header | Best simple answer when per-person identity adds no value |
| IP allow-list | Fixed corporate egress as one factor | Network location, not a person | Stable source IP | Use as a condition, not the only credential |
| Tunnel plus Access | Private origin that must not be directly reachable | Access identity plus private origin path | Browser login or service credential | Strongest Access topology for a self-hosted origin |
| Bypass plus handler signature | One third-party webhook | Provider key/signature | Provider-specific signed request | Correct for that path only |

## Seats and arithmetic

Cloudflare's current plan page says the Free plan is for teams under 50 users and costs $0. Pay-as-you-go is $7 per user per month. The page describes Remote Browser Isolation as an add-on but does not publish its current add-on price. A dated 2023 operator comparison recorded $10 per user per month; treat that as historical evidence, not today's quote.

| Administrators | Access Free | Pay-as-you-go at $7/seat/month | Shared bearer key |
| ---: | ---: | ---: | ---: |
| 1 | $0 | $7 | $0 product fee |
| 12 | $0 | $84 | $0 product fee |
| 49 | $0 | $343 | $0 product fee |
| 60 | Plan choice required; outside “under 50” positioning | $420 | $0 product fee, but 60 people sharing one key is indefensible |
| 250 | Not the free-plan fit | $1,750 | Wrong architecture |

The calculation is seats × $7. It excludes support, identity-provider cost, implementation time, and any separately quoted Remote Browser Isolation add-on. A bearer key has no Cloudflare seat line item, but secret rotation and the absence of individual attribution are costs; they are just paid in operator time and incident risk.

## What administering Access feels like

The policy surface is capable. The console has drawn specific criticism. `systemvoltage`, otherwise positive about Cloudflare's main dashboard, described the Access/Zero Trust area as a separate application that took ten seconds and redirected repeatedly, with worse UI and thin documentation.

That report is dated 2022. Do not turn it into a claim about today's page speed. Keep the durable operational lesson: the person on call needs the API paths and curl proofs in the runbook, because a graphical console can be slow, moved, or unavailable.

The positive operator case is equally concrete. `tbhb` uses Tunnel plus Access to expose only the local-development endpoints that must be public, such as webhooks, while keeping the rest of the site behind Access. That is the product boundary working: narrow public ingress, authenticated private remainder, and no directly published origin.

## Error, cause, repair

| Symptom | Cause | Repair |
| --- | --- | --- |
| `302` to `*.cloudflareaccess.com/cdn-cgi/access/login/...` | No accepted credential and the application is using interactive login behavior | Browser: complete the IdP flow. Machine: send a service token and add Service Auth, or enable the documented 401 response for Service Auth |
| `403` before the origin | Invalid service headers, no matching policy, wrong application path, or denied selector | Confirm both header names, list the application and policies, then test the exact hostname/path |
| Valid service token reaches origin but `sub` is empty | Service-token application JWT is non-human | Map verified `common_name` to a least-privilege synthetic machine identity |
| JWT signature verification fails | Wrong issuer, wrong audience, stale hard-coded key, altered token, or wrong algorithm | Fetch the team JWKS, select by `kid`, require RS256, exact issuer and exact application audience |
| Browser works; native client gets 302/403 | Client cannot add Access service-token headers | Add a general custom-header option, use the single-header mode, mTLS, or do not put that endpoint behind Access |
| Health works; every scoped API call fails | Health is public/Bypass while authenticated principal is not mapped into app membership | Fix identity mapping at the auth seam; do not widen Bypass |
| Origin accepts a claimed Access header without cryptographic verification | Application trusts attacker-supplied text | Verify JWT signature, issuer, audience and time before reading identity |
| Temporary app appears deleted but token remains | Only the Access application was deleted | Delete the service token separately; require fresh successful lists for both collections |

## Three live receipts, with bounded claims

**Access path.** A temporary application protected a unique path. Plain HTTP returned `302` before Service Auth and `403` after the Service Auth policy existed. Correct service-token headers passed Access and reached the origin, which returned its own `404`. The redacted JWT used RS256, had `type: "app"`, an audience and `common_name`, an empty `sub`, and no `email`.

**Signing keys.** A fresh unauthenticated GET of the account's team JWKS returned HTTP `200`, 4,914 JSON bytes and two RSA/RS256 signing keys. The published command uses a placeholder, not the real team name:

```sh
curl -sS "https://<team-name>.cloudflareaccess.com/cdn-cgi/access/certs" \
  | jq '{keys: [.keys[] | {kid, alg, kty, use}]}'
```

**This application's key-only admin gate.** A fresh machine request with no credential:

```sh
curl -sS -D - https://miscsubjects.com/admin \
  -H 'accept: application/json'
```

returned HTTP `401`, `application/json`, and a 47-byte body with only `error` and `login`. A scan found no stack, trace, binding, database, exception or key marker. This proves the unauthenticated failure is bounded; it does not prove the shared-key posture has person-level identity.

**Cleanup.** Successful authenticated Access application and service-token lists proved the temporary ids and names absent. No `401`, `403`, or failed parse was treated as an empty list.

Access earns its complexity when identity changes the authorization decision. If every accepted caller is the same owner and every client already holds the same operational secret, one checked key is smaller and often more reliable. Once individual revocation, IdP groups or device posture matter, use Access, verify the JWT at the origin, and give machines a principal of their own.

This chapter is part of [the Cloudflare account inventory](/a/cloudflare-os). For the private-origin and Durable Object boundary, see [Workers and Durable Objects](/a/cloudflare-os-workers).


## Sources

1. Authorization cookie — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/
2. Application token — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/application-token/
3. Validate JWTs — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/
4. Access policies — https://developers.cloudflare.com/cloudflare-one/access-controls/policies/
5. Service tokens — https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/
6. Add a self-hosted application — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app/
7. Create an Access application — https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/applications/methods/create/
8. Create a service token — https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/service_tokens/methods/create/
9. Zero Trust services plans — https://www.cloudflare.com/plans/zero-trust-services/
10. Remote Browser Isolation — https://developers.cloudflare.com/cloudflare-one/remote-browser-isolation/
11. Cloudflare Tunnel — https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
12. panva/jose — https://github.com/panva/jose
13. Add support for Cloudflare Access Service Tokens (Custom Headers) — https://github.com/jarnedemeulemeester/findroid/issues/1016
14. Support custom HTTP headers on outbound ArgoCD API requests — https://github.com/argoproj-labs/mcp-for-argocd/issues/115
15. Give Access service-token callers a usable identity — https://github.com/dataGriff/outcome-app-pattern-whiskey/issues/4
16. Cloudflare Access blocker prevents Paperclip API operations — https://github.com/lesbass/ai-newsroom/issues/4
17. Comment on Cloudflare's Access console — https://news.ycombinator.com/item?id=31332325
18. Comment on Browser Isolation pricing — https://news.ycombinator.com/item?id=35494875
19. Comment on Tunnel plus Access — https://news.ycombinator.com/item?id=41915668
20. Temporary Access request-path receipt — https://miscsubjects.com/api/articles/cloudflare-os-access
21. Service application-token receipt — https://miscsubjects.com/api/articles/cloudflare-os-access
22. Fresh JWKS and owner-gate receipt — https://miscsubjects.com/api/articles/cloudflare-os-access
23. Temporary resource deletion proof — https://miscsubjects.com/api/articles/cloudflare-os-access


---

# Cloudflare email is three products, not one mail stack

slug: cloudflare-os-email · https://miscsubjects.com/a/cloudflare-os-email · tags: cloudflare, architecture, email, cloudflare-os · updated 2026-07-26T06:16:48.370Z

# Cloudflare email is three products, not one mail stack

Cloudflare can forward inbound mail, run code on it, and send transactional mail. Each capability has a different setup gate. A verified forwarding address is not an onboarded sending domain.

| Product surface | Direction | What it does | Prerequisite | What it does not replace |
| --- | --- | --- | --- | --- |
| Email Routing | Inbound | Maps an address or catch-all to a verified destination or Worker | Domain onboarded for routing; routing MX, SPF, and DKIM records | A mailbox, outbound sender, campaign system |
| Email Workers | Inbound, plus constrained reply/forward | Runs an `email()` handler over the raw message | Active route bound to a deployed Worker; destinations verified before forwarding | General arbitrary outbound on the free plan |
| Email Service / Email Sending | Outbound | Sends transactional mail through a Worker binding, REST, or SMTP | Sending domain onboarded; Paid plan for arbitrary recipients; `send_email` binding for Workers | Marketing automation, customer subaccounts, full ESP operations |

Verified destinations are free on every plan; arbitrary recipients require Workers Paid. Paid includes 3,000 outbound messages per account each month, then costs $0.35 per 1,000. Inbound is unlimited, although processing consumes Worker resources.

[[embed:source:s1]]

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The setup begins with DNS, not code

For inbound routing, the dashboard path is:

`Cloudflare dashboard → account → Compute → Email Service → Email Routing → Onboard Domain`

For `example.com`, Cloudflare creates this root-domain shape:

```txt
MX  @  route1.mx.cloudflare.net
MX  @  route2.mx.cloudflare.net
MX  @  route3.mx.cloudflare.net
TXT @  "v=spf1 include:_spf.mx.cloudflare.net ~all"
TXT cf2024-1._domainkey  "v=DKIM1; h=sha256; k=rsa; p=<Cloudflare public key>"
```

Cloudflare assigns MX priorities. Merge the Cloudflare `include:` into an existing SPF record; two SPF records are invalid, and SPF has a ten-lookup ceiling.

[[embed:source:s2]]

For outbound Email Sending, onboarding is separate:

`Cloudflare dashboard → account → Compute → Email Service → Email Sending → Onboard Domain`

The outbound records live under `cf-bounce.example.com`, leaving the inbound root MX records alone:

```txt
MX  cf-bounce  route1.mx.cloudflare.net
MX  cf-bounce  route2.mx.cloudflare.net
MX  cf-bounce  route3.mx.cloudflare.net
TXT cf-bounce  "v=spf1 include:_spf.mx.cloudflare.net ~all"
TXT cf-bounce._domainkey  "v=DKIM1; h=sha256; k=rsa; p=<Cloudflare public key>"
TXT _dmarc  "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
```

Cloudflare says DNS commonly settles in 5–15 minutes but can take up to 24 hours. The practical gate is that the Email Sending screen shows the domain onboarded and DNS queries return the records. Start DMARC at `p=none` if other providers still send for the domain; enforce only after their identities align.

[[embed:source:s3]]

## Routing rules, verified destinations, and the catch-all

Forwarding requires a destination address that the recipient has verified. The dashboard path is:

`Compute → Email Service → Email Routing → Destination Addresses`

Cloudflare emails that address a verification link. A routing rule pointing at an unverified destination remains disabled. Once verified, create a rule under:

`Compute → Email Service → Email Routing → Routing Rules → Create routing rule`

The action is send to a verified address, send to a Worker, or drop. If two rules use the same pattern, only the first processes the message. Renaming a Worker breaks routes that point to its old name.

[[embed:source:s4]]

The catch-all is a separate rule on the Routing Rules screen. Turn it on, choose **Send to a Worker**, select the deployed Worker, and save. It catches every otherwise-unmatched local part, so add size checks, sender policy, and retention.

| Intent | Route | Destination |
| --- | --- | --- |
| Ordinary inbox alias | `hello@example.com` | Verified personal or team inbox |
| Support ingestion | `support@example.com` | Email Worker |
| Per-customer intake | `inbox+customer-id@example.com` | Email Worker with subaddressing enabled |
| Any unmatched local part | Catch-all | Worker, then explicit allow/reject logic |
| Address that should appear valid but retain nothing | Named rule | Drop |

With subaddressing enabled, `inbox+acme@example.com` matches `inbox@example.com` while preserving `+acme` in `message.to`.

## A runnable inbound Worker that parses MIME and stores attachments

The useful inbound architecture is short:

`Cloudflare MX → Email Routing catch-all → email() handler → postal-mime → R2 objects + application record`

Install `postal-mime`, bind an R2 bucket, and deploy the Worker:

```bash
npm install postal-mime
npx wrangler r2 bucket create inbound-attachments
npx wrangler deploy
```

```jsonc
{
  "name": "inbound-mail",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-01",
  "r2_buckets": [
    { "binding": "ATTACHMENTS", "bucket_name": "inbound-attachments" }
  ]
}
```

```ts
import PostalMime from "postal-mime";

interface Env {
  ATTACHMENTS: R2Bucket;
  FORWARD_TO: string;
}

function safeName(name: string): string {
  return name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 180);
}

export default {
  async email(message: ForwardableEmailMessage, env: Env): Promise<void> {
    if (message.rawSize > 25 * 1024 * 1024) {
      message.setReject("Message exceeds the 25 MiB inbound limit");
      return;
    }

    const parsed = await PostalMime.parse(message.raw);
    const received = new Date().toISOString();
    const mailId = crypto.randomUUID();

    for (const [index, attachment] of (parsed.attachments || []).entries()) {
      const filename = safeName(attachment.filename || `attachment-${index}`);
      const key = `mail/${received.slice(0, 10)}/${mailId}/${filename}`;

      await env.ATTACHMENTS.put(key, attachment.content, {
        httpMetadata: {
          contentType: attachment.mimeType || "application/octet-stream"
        },
        customMetadata: {
          envelopeFrom: message.from,
          envelopeTo: message.to,
          subject: (parsed.subject || "").slice(0, 500)
        }
      });
    }

    await message.forward(env.FORWARD_TO);
  }
} satisfies ExportedHandler<Env>;
```

`message.raw` is a single stream. If two consumers need it, buffer once with `new Response(message.raw).arrayBuffer()`. The handler also exposes envelope addresses, headers, size, `setReject()`, `forward()`, and `reply()`.

[[embed:source:s5]]

`postal-mime` handles multipart boundaries, encodings, and character sets. Treat filenames and MIME types as untrusted. Generate the key, cap size, and scan before serving.

[[embed:source:s6]]

One operator reports: “I’m using Cloudflare Email Routing with a catch-all address that triggers a Worker. The Worker parses the email and stores the attachments in R2.” It is one working implementation, not a universal guarantee.

[[embed:source:s7]]

For the storage half, see [R2 cuts a 10 TB delivery bill from $923 to $18.45](/a/cloudflare-os-r2). That chapter covers binding calls, public versus private objects, versioning gaps, and cost.

## Replying is not the same as arbitrary sending

The inbound message object can forward to verified routing destinations. It can also reply with a raw `EmailMessage` constructed from `cloudflare:email`:

```ts
import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext";

export default {
  async email(message: ForwardableEmailMessage): Promise<void> {
    const msg = createMimeMessage();
    msg.setSender({ name: "Example support", addr: "support@example.com" });
    msg.setRecipient(message.from);
    msg.setSubject("Re: " + (message.headers.get("subject") || "your message"));
    msg.addMessage({
      contentType: "text/plain",
      data: "We received your message."
    });

    const reply = new EmailMessage(
      "support@example.com",
      message.from,
      msg.asRaw()
    );
    await message.reply(reply);
  }
};
```

This is tied to the inbound message. `message.reply()` throws above 100 `References` entries, and forwarding destinations must be verified.

[[embed:source:s8]]

## New outbound mail uses Email Service

After onboarding, bind Email Service to a paid Worker:

```jsonc
{
  "send_email": [
    { "name": "EMAIL" }
  ]
}
```

Call `env.EMAIL.send()` with an onboarded-domain `from`, recipient objects, subject, text, and HTML.

The prerequisite chain is:

1. The zone is in the Cloudflare account.
2. Email Sending shows the domain as onboarded.
3. The `cf-bounce` MX, SPF, DKIM, and DMARC records resolve.
4. The Worker is on Workers Paid for arbitrary recipients.
5. The Worker has a `send_email` binding and uses the onboarded domain in `from`.
6. The message stays inside recipient, header, and size limits.

Before onboarding, sends are limited to verified destinations and routing domains. After onboarding, arbitrary recipients are allowed, subject to daily quota and reputation. New accounts start with a conservative quota that Cloudflare adjusts over time rather than publishing one universal number.

[[embed:source:s9]]

Current limits include 50 combined recipients, 998 subject characters, 16 KB of custom headers, and 5 MiB total. Verified-destination mail may be 25 MiB. A zone may have 30 combined Routing and Sending domains; Routing allows 200 rules per domain and 200 verified destinations per account.

## The site's own split proves why the prerequisite must be visible

This build has a Pages endpoint at `POST /api/email/send`. It authenticates the owner, then proxies the body to a sibling Worker because Pages cannot carry the `send_email` binding. The sibling declares:

```toml
[[send_email]]
name = "EMAIL"
```

The public diagnostic response currently says:

```json
{
  "inbound": {
    "loop@miscsubjects.com": "forward → owner@redacted",
    "build@miscsubjects.com": "worker → ledger + forward"
  },
  "sending": "Enable Email Sending on miscsubjects.com in CF dashboard (Pages cannot bind send_email)"
}
```

Code and a binding are not proof of a send-capable domain. The prerequisite remains open until onboarding and an observed delivery receipt.

[[embed:source:s10]]

On July 26, 2026, `miscsubjects.com` resolved three Cloudflare routing MX records, root SPF, `cf2024-1` DKIM, and DMARC `p=reject`. The sending selector also returned a key, but DNS alone does not prove onboarding or delivery. No email was sent.

[[embed:source:s11]]

## SPF, DKIM, DMARC, and ARC in plain language

| Mechanism | What it asserts | What forwarding changes |
| --- | --- | --- |
| SPF | The connecting server is authorized for the envelope sender's domain | A forwarder connects from a new IP, so the original SPF relationship can break |
| DKIM | A domain signed selected headers and body bytes with a key published in DNS | It can survive forwarding if the signed bytes remain intact |
| DMARC | The visible `From:` domain must align with a passing SPF or DKIM identity, then applies a policy | Forwarding can disturb SPF; mailing-list or gateway modifications can disturb DKIM |
| ARC | Intermediaries preserve a signed chain of the authentication result they observed | A destination can evaluate the forwarder's attestation when direct SPF or DKIM no longer tells the whole story |

Cloudflare uses Sender Rewriting Scheme, changing the envelope sender so SPF can pass from its relay while leaving visible `From:` unchanged. Routing adds DKIM, and ARC preserves authentication results through the forwarding hop.

[[embed:source:s12]]

ARC is evidence, not an override switch. The final provider still applies its own reputation, block-list, policy, and content decisions. A message can authenticate and still be rejected or placed in spam.

## The Outlook reports are real, but they are not a universal result

Two independent operator reports describe Microsoft blocking Cloudflare Email Routing IP ranges. Handy-Man writes that Outlook “just blocks Cloudflare IP ranges and emails never get routed to my Outlook mail box.”

[[embed:source:s13]]

In a separate discussion, doubled112 reports that Microsoft “intermittently block Cloudflare email routing IPs too,” despite the surrounding SPF, DKIM, and DMARC work.

[[embed:source:s14]]

A third independent account documents Outlook failures through Cloudflare routing.

[[embed:source:s15]]

These reports establish a failure mode, not prevalence. Shared relays can inherit reputation from other traffic. Authentication improves identity evidence; it does not require a provider to accept an IP.

When forwarded mail disappears, inspect the Email Routing activity log first. Separate these cases:

| Symptom | Likely cause | Check | Fix |
| --- | --- | --- | --- |
| Rule is disabled | Destination never verified | Destination Addresses status | Resend verification and activate the rule |
| No routing event | MX or rule mismatch | `dig MX`, rule order, exact recipient | Finish onboarding; repair the pattern |
| Worker invocation failed | CPU, memory, exception, or renamed Worker | Workers logs; route target | Fix the exception or rebind the renamed Worker |
| Forward call rejects | Destination is not verified | Destination list and Worker log | Verify the exact address before forwarding |
| Routing says delivered, inbox has nothing | Destination provider rejected, deferred, or filtered it | Routing activity, SMTP response, spam/quarantine | Test another verified destination; give Cloudflare the event and SMTP evidence |
| Sending call succeeds but Routing summary says “dropped” | Outbound Worker mail is shown that way in Routing | Email Sending metrics and logs | Use Email Sending observability, not the Routing label |
| Local attachment test throws `Cannot serialize value: [object ArrayBuffer]` | Local runtime limitation | Reproduce on deployed Worker | Test binary attachments against the deployed Worker |
| Reply throws on a long thread | More than 100 References entries | Count `References` values | Start a new message or trim the reply path |

Cloudflare warns about multiple SPF records, missing selectors, alignment, new-domain reputation, bounces, and provider filtering. Binary attachments can also hit a local ArrayBuffer serialization limit even when deployment works.

[[embed:source:s16]]

One developer followed the `cloudflare:email` and `EmailMessage` path, verified an address, and reported no arrival or error. The unanswered report cannot establish cause. It does show why a resolved Promise is not delivery proof. Check destination verification, onboarding, Email Sending logs, spam, quarantine, and SMTP events.

[[embed:source:s17]]

## Where Cloudflare Email Service still is not SendGrid

Transactional sending is not the whole ESP product.

One SendGrid operator creates subaccounts by API so customers can verify their own domains with DKIM and SPF. Cloudflare's documented flow onboards zones from its account; it does not document an equivalent delegated subaccount system. Do not promise one without a specific API and proof.

[[embed:source:s18]]

The gaps are operational: durable bounce handling, suppression recovery, versioned templates, tenant analytics, and marketing consent or unsubscribe controls. Conservative daily quotas, reputation scaling, 50 recipients per message, and the limit-increase process keep “transactional” a real boundary.

A project can build those layers on Workers, D1, R2, and Queues. One operator is building an AGPL email platform in each user's Cloudflare account. Once those layers are included, the work is an email product, not a send call.

[[embed:source:s19]]

## Cost is attractive; replacement scope is the constraint

At current list price, Cloudflare Paid includes 3,000 outbound messages monthly and charges $0.35 per 1,000 after that.

| Outbound transactional volume | Cloudflare Email Service usage line | What the arithmetic excludes |
| --- | --- | --- |
| 3,000/month | Included in Workers Paid | Paid plan itself; application work |
| 10,000/month | $2.45 above the included 3,000 | Templates, bounce workflow, analytics |
| 100,000/month | $33.95 above the included 3,000 | Reputation operations and support |
| 1,000,000/month | $348.95 above the included 3,000 | Quota approval and product controls |

The formula is `max(0, messages - 3,000) / 1,000 × $0.35`. Accepted hard bounces count; API-boundary rejections do not. Verified-destination sends are free.

Resend packages volume with retention, domain, team, and feature limits. Compare the operating requirement, not only cost per thousand.

[[embed:source:s20]]

Cloudflare's price works best for receipts, password resets, alerts, and other application mail from one owned domain. Customer-domain onboarding, mature suppressions, templates, analytics, and marketing change the comparison.

## The decision table

| Workload | Best first choice | Why |
| --- | --- | --- |
| Receive-only aliases into an existing inbox | Email Routing | No mailbox migration; verified forwarding destination |
| Receive and inspect, reject, archive, or branch | Email Routing → Email Worker | Code runs at the SMTP ingress and can store to R2 |
| Inbound email webhook with attachments | Catch-all or named route → Worker → parser → private R2 | One event path; binary payload leaves D1 |
| Transactional mail from one owned domain | Email Service on Workers Paid | Onboarded domain, low unit price, native binding |
| Send only to a few fixed internal addresses | Verified destinations | Free, constrained anti-abuse path |
| Customer-owned sending domains and subaccounts | Established ESP until proven otherwise | Tenant onboarding and reputation boundaries are product features |
| Marketing campaigns and newsletters | Marketing ESP | Consent, unsubscribe, segmentation, templates, analytics |
| Full hosted mailbox with IMAP, folders, search, calendars | Mail provider | Cloudflare Email Service is transport and compute, not a mailbox |

Migrate in the same order: verify inbound routing and destination receipt, then the Worker and stored R2 objects. Onboard sending separately and keep the ESP until bounces, suppressions, quotas, logs, and delivery have owners.

The final test is one real message in, the intended Worker invocation, the expected object or forward, and the result at the destination. Outbound needs the production send plus recipient delivery. Until then, it is configured, not proven.


## Sources

1. Email Service pricing — https://developers.cloudflare.com/email-service/platform/pricing/
2. Email Service troubleshooting — https://developers.cloudflare.com/email-service/reference/troubleshooting/
3. Email Service domain configuration — https://developers.cloudflare.com/email-service/configuration/domains/
4. Email routing rules and addresses — https://developers.cloudflare.com/email-service/configuration/email-routing-addresses/
5. Email handler Workers API — https://developers.cloudflare.com/email-service/api/route-emails/email-handler/
6. postal-mime source repository — https://github.com/postalsys/postal-mime
7. Show HN: Webhook API – inbound email –> webhook — https://news.ycombinator.com/item?id=47932438
8. Email Service limits — https://developers.cloudflare.com/email-service/platform/limits/
9. Email Service quotas and onboarding limits — https://developers.cloudflare.com/email-service/platform/limits/
10. Live miscsubjects email endpoint — https://miscsubjects.com/api/email/send
11. Live DNS measurement — https://miscsubjects.com/a/cloudflare-os-email
12. Email Service postmaster reference — https://developers.cloudflare.com/email-service/reference/postmaster/
13. Cloudflare Email Service: private beta — https://news.ycombinator.com/item?id=45373715
14. DKIM2 and DMARCbis Have Landed — https://news.ycombinator.com/item?id=48837913
15. When things start to fail: Cloudflare Email Routing — https://dariusz.wieckiewicz.org/en/when-things-start-to-fail-cloudflare-email-routing/
16. Cloudflare Email Service troubleshooting — https://developers.cloudflare.com/email-service/reference/troubleshooting/
17. Cannot send emails from Cloudflare worker — https://stackoverflow.com/questions/79733052/cannot-send-emails-from-cloudflare-worker
18. Cloudflare Email Service: private beta — https://news.ycombinator.com/item?id=45376469
19. Building an email platform on Workers + D1 + R2 + Queues — would like architecture feedback — https://old.reddit.com/r/CloudFlare/comments/1u90bev/building_an_email_platform_on_workers_d1_r2/
20. Resend pricing — https://resend.com/pricing


---

# waitUntil, Queues, Workflows or Cron: choose by durability

slug: cloudflare-os-async · https://miscsubjects.com/a/cloudflare-os-async · tags: cloudflare, architecture, queues, cloudflare-os · updated 2026-07-26T03:59:38.467Z

A request has to return now. The work behind it takes ninety seconds, or ten minutes, or has to happen at 4am whether or not anyone visits. Cloudflare gives you four ways to move that work off the response path, and they are not interchangeable: pick the wrong one and you either lose the job silently, pay for durability you never needed, or discover in production that the thing you tested locally cannot exist there.

This page settles the choice, gives the working configuration for each, and publishes the measured behaviour of the two that run in this account.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Four mechanisms, and the one property that decides between them

The property is **durability** — whether the work survives the death of the invocation that started it. Duration is the second question, not the first.

| | `ctx.waitUntil` | Queue (`queue()` consumer) | Workflow | Cron Trigger |
| --- | --- | --- | --- | --- |
| Survives the request's Worker dying | No | Yes — the message is persisted before `send()` resolves | Yes — each completed step is persisted | N/A, nothing starts it but the clock |
| Retry semantics | None. The promise is cancelled | Whole batch retried; `max_retries` default 3; per-message `ack()` / `retry()` | Per-step; default `limit: 5`, `delay: 10000`, `backoff: "exponential"`, `timeout: "10 minutes"` | None. A failed tick is a lost tick |
| Maximum duration | 30 s after the response is sent | 15 minutes wall clock per consumer invocation | Unlimited wall clock per step; `step.sleep` up to 365 days | 15 minutes wall clock |
| Ordering | N/A | **Not guaranteed** | Guaranteed within one instance — single-threaded | By schedule only |
| Delivery guarantee | None | At-least-once | At-least-once per step; the step *result* is cached, so a completed step is not re-executed | At-least-once |
| Observability | Workers Logs only | Queue metrics, DLQ contents, consumer logs | `wrangler workflows instances list/describe`, REST API, dashboard | Past Cron Events (last 100), Workers Logs, GraphQL Analytics API |
| Cost unit | Nothing beyond the parent request | $0.40 per million operations; one message ≈ 3 operations (write, read, delete) | Requests + CPU ms + GB-month storage + $0.80 per additional 100,000 steps | One Worker request per tick |
| Redeploy mid-flight | Undocumented; assume the in-flight promise is lost | Unacked messages are redelivered to the new code | Undocumented. The step journal survives, so completed steps are not re-run, but a changed step list is unhandled | Next tick runs the new code |

Two rows in that table are marked undocumented, and that is a real gap rather than a research failure. Tim, writing at thisisacomputer.com, tried to find the answer for Workflows and reported: "Making changes to durable workflows is tricky. Cloudflare has no documentation around this. You're on your own, so be careful." His own working rules — appending a step at the end is safe, inserting one in the middle is probably not — are inference, and he labels them as inference. Treat them the same way.

## `ctx.waitUntil` buys thirty seconds and no promises

`ctx.waitUntil(promise)` tells the runtime to keep the invocation alive after the response has been sent. It is the third argument to every handler (`fetch(request, env, ctx)`), and it is not storage: nothing is written anywhere, and there is no retry.

The limit is hard and shared. From the Context API reference: "For HTTP-triggered Workers, `ctx.waitUntil()` can extend execution for up to 30 seconds after the response is sent or the client disconnects. This is not a limit on the total wall time of an HTTP request. This time limit is shared across all `waitUntil()` calls within the same request."

When you exceed it, the promises are cancelled and this exact line appears in Workers Logs:

```
waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled.
```

Cloudflare's own docs name the escape hatch in the same paragraph: "If the work cannot finish within the `waitUntil()` time limit, send messages to a Queue and process them in a separate consumer Worker."

This build uses `waitUntil` where losing the work costs nothing — cache warming and snapshot refresh in `functions/_middleware.js`, event logging in `functions/_lib/event_log.js`, ledger writes in `functions/api/dispatch.js`. Here is the real pattern from `functions/_middleware.js`, where a slow render is allowed to finish after a cached fallback has already been served:

```js
// functions/_middleware.js
context.waitUntil(
  render
    .then((late) =>
      late && late.status === 200 ? refreshLastGood(env, key, late) : null,
    )
);
```

If that promise dies, the next request re-renders. Nothing is lost that matters. That is the only test that licenses `waitUntil`.

## A queue is the cheapest thing that survives your Worker dying

A queue has two halves. The **producer** holds a binding and calls `send()`. The **consumer** exports a `queue()` handler and is invoked with batches. Both halves can live in the same Worker.

Configuration, in `wrangler.toml`:

```toml
[[queues.producers]]
binding = "TASKS"
queue = "loop-tasks"

[[queues.consumers]]
queue = "loop-tasks"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 3
dead_letter_queue = "loop-tasks-dlq"
```

Create the queues first, or the deploy fails:

```sh
npx wrangler queues create loop-tasks
npx wrangler queues create loop-tasks-dlq
```

Expected output for each: `Creating queue 'loop-tasks'.` followed by `Created queue 'loop-tasks'.`

The producer. `send()` resolves once the message is durably written, so awaiting it is what makes the handoff safe:

```js
// producer — returns immediately, work is now someone else's problem
export async function onRequestPost({ env }) {
  await env.TASKS.send({ key: "REBUILD_INDEX", body: "", ts: Date.now() });
  return Response.json({ queued: true }, { status: 202 });
}
```

The consumer. `ack()` per message is the difference between one poison message and ten redeliveries:

```js
export default {
  async queue(batch, env) {
    for (const msg of batch.messages) {
      try {
        await doTheWork(msg.body, env);
        msg.ack();          // this message will not be redelivered
      } catch {
        msg.retry();        // only this message goes back on the queue
      }
    }
  },
};
```

Without the per-message `ack()`, one failure takes the whole batch with it. The docs are explicit: "if a batch of 10 messages is delivered, but the 8th message fails to be delivered, all 10 messages will be retried and thus redelivered to your consumer in full."

`max_batch_size` and `max_batch_timeout` race each other — whichever is reached first triggers delivery. With the defaults (10 messages, 5 seconds) a low-traffic queue always waits out the timeout. That is exactly what the enqueue-to-consumption measurement below shows.

Two properties will bite you if you skim them. **Order is not preserved:** "Queues does not guarantee that messages will be delivered to a consumer in the same order in which they are published." **Delivery is at-least-once**, not exactly-once: "messages are guaranteed to be delivered at least once, and in rare occasions, may be delivered more than once." The documented fix is an idempotency key generated at write time and used as the primary key or the upstream API's idempotency header — not a de-duplication table you maintain yourself.

## Workflows pay for durability one step at a time

A Workflow is a class extending `WorkflowEntrypoint` with a `run(event, step)` method. Every `step.do(name, fn)` result is persisted. If the instance dies and resumes, completed steps return their cached value instead of re-executing. The step name is the cache key, which is why the docs insist names be deterministic.

Configuration:

```toml
[[workflows]]
name = "deliver-workflow"
binding = "DELIVER_WF"
class_name = "DeliverWorkflow"
```

The code, from `workers/sibling/src/index.js` in this build, trimmed to the shape:

```js
import { WorkflowEntrypoint } from 'cloudflare:workers';

export class DeliverWorkflow extends WorkflowEntrypoint {
  async run(event, step) {
    const tickAt = await step.do('record start', async () => buildNowIso());

    const pending = await step.do('list pending', async () => {
      const r = await this.env.DB.prepare(
        "SELECT id, asset_id, channel, recipient FROM pending_deliveries " +
        "WHERE status IN ('queued','polling') ORDER BY id LIMIT 25"
      ).all();
      return (r.results || []).map(x => ({ id: x.id, channel: x.channel }));
    });

    for (const job of pending) {
      await step.do(`deliver ${job.id}`,
        { retries: { limit: 3, delay: '10 seconds', backoff: 'exponential' } },
        async () => {
          const resp = await fetch(PAGES_BASE + '/api/deliver', {
            method: 'POST', headers: deliverHeaders(this.env),
            body: JSON.stringify({ id: job.id }),
          });
          return { id: job.id, status: resp.status };
        });
    }
    return { tickAt, attempted: pending.length };
  }
}
```

Note `step.do(\`deliver ${job.id}\`)`. The name is dynamic but deterministic — it comes from a database row id, traversed in a fixed order. A name built from `Date.now()` or `Math.random()` would never hit its cache and would re-run the side effect on every resume.

Pacing uses `step.sleep`, which costs nothing while it waits. The sibling Worker's self-test workflow spaces its questions this way:

```js
await step.sleep(`pace ${i}`, '30 seconds');
```

A sleeping instance does not count against the concurrency limit: "Instances that are in a `waiting` state — either sleeping via `step.sleep`, waiting for a retry, or waiting for an event via `step.waitForEvent` — do **not** count towards concurrency limits."

Trigger and inspect from the command line:

```sh
npx wrangler workflows trigger deliver-workflow '{"reason":"manual"}'
npx wrangler workflows instances list deliver-workflow
npx wrangler workflows instances describe deliver-workflow <INSTANCE_ID>
```

`instances describe` is the one that shows per-step status. The binding's `instance.status()` does not — it returns only queued/running/complete plus the output or error.

The published limits moved substantially between August 2025 and now, and the older independent write-up is still the top search result, so both numbers are here. Tim measured against the platform as it stood in 2025-08: "You're limited to 25 concurrent instances on the free tier or 4500 on the paid tier" and "1024 steps per workflow". The current limits page says 100 concurrent on Free and 50,000 on Paid, with 1,024 steps on Free and 10,000 (configurable to 25,000) on Paid. Both are accurate for their date. The lesson is to read the limits page on the day you design, not the blog post.

## Cron is the only one that starts itself

A Cron Trigger maps a five-field cron expression to a `scheduled()` handler. It runs on UTC. Nothing invokes it but the clock.

```toml
[triggers]
crons = ["*/1 * * * *", "0 4 * * *"]
```

```js
export default {
  async scheduled(controller, env, ctx) {
    if (controller.cron === '0 4 * * *') {
      ctx.waitUntil(fetch(BASE + '/api/daily-report', { method: 'POST' }));
      return;
    }
    ctx.waitUntil(fetch(BASE + '/api/tick', { method: 'POST' }));
  },
};
```

`controller.cron` is the string that fired, so one handler serves every schedule. `controller.scheduledTime` is the intended fire time in epoch milliseconds — use that, not `Date.now()`, when a tick must be idempotent, because a retried invocation carries the same `scheduledTime`.

**The minimum interval is one minute.** `* * * * *` is the finest expression the five-field syntax allows. Anything faster needs a Durable Object alarm.

**Overlap is not prevented.** Cloudflare does not skip or queue a tick because the previous one is still running. If your job can exceed its interval, you must gate it yourself. This build gates with a KV flag read at the top of each branch, so a disabled loop costs one KV read and nothing else:

```js
const on = env.KV ? await env.KV.get('writer_queue_autorun') : null;
if (on !== '1') return;
```

A second pattern in the same handler thins a per-minute schedule down to a five-minute one without adding a second cron entry:

```js
if (on === '1' && new Date().getMinutes() % 5 === 0) { /* ... */ }
```

**Seeing whether a tick ran.** The dashboard keeps only the last 100 invocations under **Settings → Trigger Events → View events**, and takes up to 30 minutes to start showing anything for a new Worker. `npx wrangler tail loop-safe-sibling --format=pretty` shows them live. Neither is a record. This build writes its own row instead, into a D1 `log` table, which is what made the measurements at the bottom of this page possible.

**Deploy semantics are destructive.** From the Cron Triggers docs: "When deploying a Worker with Wrangler any previous Cron Triggers are replaced with those specified in the `triggers` array." An empty `crons` array deletes them all; omitting the key entirely leaves them alone. And changes take up to 15 minutes to propagate.

## There is no way to dead-letter a message you already know is poison

A dead-letter queue only receives a message after `max_retries` is exhausted. The DLQ docs say so plainly: a DLQ "represents where messages are sent when a delivery failure occurs with a consumer after `max_retries` is reached."

That leaves a hole. When your consumer reads a message and knows immediately — malformed JSON, a deleted tenant, a schema version you no longer support — that retrying is pointless, there is no API to send it straight to the DLQ. `alexander-zuev` filed it on `cloudflare/workers-sdk` as issue 13816: "Cloudflare Queues push consumers currently expose ack/retry controls, and configured DLQs receive messages only after max_retries is exhausted."

Three workarounds exist. None is clean.

| Workaround | What it costs | What you lose |
| --- | --- | --- |
| `msg.ack()` and drop it | Nothing | The payload. No record of what failed, nowhere to replay it from |
| `msg.retry()` until retries are exhausted | 3 extra read operations per message, plus 3 more consumer invocations, plus the wall-clock delay before it lands | Nothing, eventually — but your consumer logs fill with failures that were never going to succeed |
| `env.FAILURES.send(msg.body)` then `msg.ack()` | One extra queue, and ~3 operations per failed message | Nothing. You now own the retention policy and the replay path |

**Pick the third.** The arithmetic decides it: burning retries costs roughly the same operations as writing to a parallel failure queue, and buys you nothing except latency and noise. Explicitly writing the failure gives you the same durable record a DLQ would have given you, plus the failure reason, which a real DLQ does not carry.

```js
async queue(batch, env) {
  for (const msg of batch.messages) {
    let job;
    try { job = JSON.parse(msg.body); }
    catch (e) {
      await env.FAILURES.send({ raw: msg.body, reason: 'unparseable', at: Date.now() });
      msg.ack();                       // non-retryable — do not burn retries
      continue;
    }
    try { await run(job, env); msg.ack(); }
    catch (e) { msg.retry(); }         // transient — this one deserves retries
  }
}
```

Keep a real DLQ configured as well. It catches the transient failures that genuinely exhaust their retries, which is the case a DLQ was designed for. Messages sitting in a DLQ with no consumer attached are deleted after four days.

## Declaring a queue producer breaks every route under `--remote`

This is the failure that costs the most time, because it looks like your code.

`tobihagemann` filed issue 9642 on `cloudflare/workers-sdk`: "When a queue producer is configured in `wrangler.toml`, ALL API routes return 500 Internal Server Error when using `wrangler dev --remote`, even routes that don't use the queue binding." Eight reactions. Local dev is fine. Production is fine. Only `--remote` breaks, and it breaks routes that never touch the binding.

It compounds. `Cherry` filed the wider version as issue 5543: "If you run a worker that uses Queues with `dev --remote`, it implodes and is completely unusable. You get obscure \"Script not found\" errors." Because several bindings — Browser Rendering and Analytics Engine among them — only function under `--remote`, a Worker that uses Queues *and* Browser Rendering cannot be exercised end to end in development at all.

This build is exactly that Worker. `workers/sibling/wrangler.toml` declares `[[queues.producers]]`, `[[queues.consumers]]`, and `[browser] binding = "MYBROWSER"` in the same file.

**The workaround is to stop trying to run one session.** Split the surface:

1. Run everything else in the local emulator: `npx wrangler dev` (Miniflare runs the same Queues implementation Cloudflare runs globally, and the local queue actually delivers to your local consumer).
2. For the `--remote`-only bindings, put them behind a small separate Worker with no queue bindings in its config, and run *that* with `npx wrangler dev --remote`. Call it over a service binding.
3. Test the queue path itself against a preview deployment rather than a dev session: `npx wrangler deploy --name my-worker-preview` and drive it with real requests.

Related, and worth knowing before you debug it for an hour: `danieltroger` filed issue 14101, where under local `wrangler dev` "a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed." The local Workflows engine stores step results in SQLite and the serialization path for typed arrays is what breaks, not the size limit you would expect from the 1 MiB documented ceiling.

## Ship payments somewhere else; keep Workflows for the cheap reports

Two credible criticisms of Workflows' production readiness exist, and they point in different directions.

The first is about lifecycle. Commenting on Hacker News, `aroman` wrote that Cloudflare was "claiming Workflows had reached \"GA\" status before offering a way to delete workflows... not via wrangler, not the dashboard, not the API." That gap has since closed: `wrangler workflows delete [NAME]` is documented today, with the note "when deleting a workflow, it will also delete it's own instances", alongside `instances terminate`, `pause`, `resume` and `restart`. The complaint was accurate when made and is no longer a blocker. What survives it is the pattern — check that the operation you will need at 3am exists before you build on the primitive, not after.

The second is about where Workflows belongs, and it is the more useful one because it comes with a policy already in production. `saxenaabhi`, on the "Building durable workflows on Postgres" thread, splits work across three durable-execution engines and uses Cloudflare Workflows for exactly one of them: payments go to Restate "since its faster than cf workflows, independent of cf and its downtime and self-hostable vendor-lock-in free", Workflows handles non-critical CSV and PDF report generation because it is very cheap, and DBOS covers the cases that need atomicity with a Postgres transaction.

**That boundary is the recommendation of this page.** Use Cloudflare Workflows when the job is (a) already inside Cloudflare, (b) tolerant of Cloudflare being down, and (c) cheap enough that the price advantage is the point. Move it out when a Cloudflare outage means the job must still run — a payment, a regulatory filing, an SLA-bound callback — because a durable execution engine that is unavailable is not durable from the caller's side. That is a decision about correlated failure, not about features.

## What this build actually runs on a schedule

The Pages project (`wrangler.toml`) has no `[triggers]` block at all. Pages Functions have no `scheduled()` handler. Every scheduled action is owned by one bound Worker, `loop-safe-sibling`, at `workers/sibling/`.

```toml
# workers/sibling/wrangler.toml
[triggers]
# */1 = build ticks · 0 4 * * * = 9:00 PM America/Los_Angeles (PDT → 04:00 UTC)
crons = ["*/1 * * * *", "0 4 * * *"]
```

| Cron line | UTC meaning | What it does | Where |
| --- | --- | --- | --- |
| `*/1 * * * *` | every minute | Writes a `sibling.cron` row to D1, fires `/api/deliver`, then fans out 11 more gated jobs — task runner, protocol writer, OIP review, editorial board, article Q&A, writer queue, graph grow, GitHub loop, commit fold, automation sweep | `workers/sibling/src/index.js:351` |
| `0 4 * * *` | 04:00 UTC = 21:00 America/Los_Angeles during PDT | Posts the daily Stripe summary to WhatsApp, then returns without running the per-minute fan-out | `workers/sibling/src/index.js:354` |

Every job in the per-minute fan-out is wrapped in `ctx.waitUntil` and gated on a KV flag, so a disabled loop is one KV read. That is the whole design: cron provides the heartbeat, `waitUntil` provides the parallelism, KV provides the switch, and nothing in the tick is allowed to be work that matters if it is lost. Work that matters goes to `env.TASKS.send()` at `functions/_lib/fn_runners.js:1146`, or to a Workflow.

## Durable Object alarms win when the schedule belongs to one entity

A fifth option, and often the right one. A Durable Object schedules its own wake-up with `setAlarm()`, and the runtime calls its `alarm()` handler at that time. Alarms have "guaranteed at-least-once execution and are retried automatically when the `alarm()` handler throws", with "exponential backoff starting at a 2 second delay from the first failure with up to 6 retries allowed".

Choose an alarm over a cron when the schedule is per-entity rather than global: one user's trial expiry, one document's autosave, one game's tick. A Worker gets three Cron Triggers; an account gets an unbounded number of Durable Objects, each with its own alarm. Choose an alarm over a queue when the work needs the co-located strongly-consistent storage the object already holds.

Alarms also have the worst failure mode of anything on this page — a self-rescheduling alarm that never terminates bills continuously and silently. The mechanics of that, and the $34,895 case, are in [Workers, Durable Objects and the cost of getting the object model wrong](/a/cloudflare-os-workers). Read it before you write your first `setAlarm()`.

## Answer five questions in order and the mechanism is decided

1. **Does the work start from a request, or from the clock?** From the clock, globally → **Cron Trigger**. From the clock, per-entity → **Durable Object alarm**. From a request → keep going.
2. **If this work is silently lost, does anything break?** No → **`ctx.waitUntil`**. Stop here; it is free and it is one line. Yes → keep going.
3. **Will it finish inside 30 seconds after the response?** No → skip to 4. Yes, but it must not be lost → still skip to 4. `waitUntil` has no retry, so "must not be lost" always leaves it.
4. **Is it one unit of work, or several with side effects between them?** One unit, idempotent, under 15 minutes → **Queue**. Several steps where re-running step 3 after step 4 fails would double-charge, double-send, or double-write → **Workflow**.
5. **Must it still run when Cloudflare is down?** Yes → an external durable-execution engine, and accept the operational cost. No → the answer from step 4 stands.

The common wrong turn is step 4. A queue retry re-runs your *entire* consumer body for that message. If that body has already sent an email and then fails at the database write, the retry sends a second email. A Workflow's `step.do` is the only mechanism here that stops that, because the completed step returns its cached result instead of re-executing.

## The bill, per mechanism, with the arithmetic

| Mechanism | Rate | Worked example |
| --- | --- | --- |
| `ctx.waitUntil` | No separate charge. CPU time counts against the parent request | 1M requests each doing a 5 ms `waitUntil` write: no line item, the CPU already counted |
| Queues | $0.40 per million operations; 1M operations/month included on Paid. One 64 KB message = 3 ops (write, read, delete) | 1M messages/month = 3M ops − 1M included = 2M billed = **$0.80/month** |
| Queues, with retries | Each retry adds one read op per message | Same 1M messages, 5% failing and retried 3× before the DLQ write: +150,000 reads +50,000 DLQ writes ≈ 2.2M billed ≈ **$0.88/month** |
| Workflows | 10M requests + 30M CPU-ms + 500,000 steps + 1 GB storage included on Paid; then $0.30/M requests, $0.02/M CPU-ms, $0.80 per additional 100,000 steps, $0.20/GB-month | 100,000 instances/month × 8 steps = 800,000 steps − 500,000 included = 300,000 billed = 3 × $0.80 = **$2.40/month** in steps, before CPU |
| Cron Triggers | One Worker request per tick, at the standard Workers rate | `*/1 * * * *` = 1,440 requests/day = 43,800/month, inside the 10M included on Paid = **$0** marginal |

Two notes the tables hide. Workflows step and storage billing is not live yet — Cloudflare's pricing page states billing "will apply starting August 10th, 2026", so the $2.40 above is what that workload will cost, not what it costs today. And a queue message over 64 KB is charged as multiple messages: a 127 KB message incurs two operation charges on every write, read and delete.

The number that should decide anything here is not the monthly total — all four are cheap at small scale. It is the retry multiplier. A consumer that throws on a permanently broken message costs you 4× the reads and 4× the invocations for a message that was never going to succeed, forever, until you fix it.

## Error strings and what each one means

| Symptom | Cause | Fix |
| --- | --- | --- |
| `waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled.` | Total `waitUntil` work in one request exceeded 30 s | Move the work to a queue. The limit is shared across every `waitUntil` in that request, so splitting into more calls does not help |
| Every route returns 500 under `wrangler dev --remote`, including routes with no queue code | A `[[queues.producers]]` binding exists in the config — `workers-sdk` issue 9642 | Do not use `--remote` on a Worker with queue bindings. Use plain `npx wrangler dev`, or a preview deployment |
| `Script not found` from `wrangler dev --remote` on a Worker with Queues | Same root cause, wider blast radius — `workers-sdk` issue 5543 | Split the `--remote`-only bindings into a second Worker without queue bindings and reach it over a service binding |
| `string or blob too big: SQLITE_TOOBIG` from a Workflow step under local `wrangler dev` | A `Uint8Array` step output around 200 KB hits the local SQLite serialization path — `workers-sdk` issue 14101 | Return an `ArrayBuffer` or a string instead, or write the bytes to R2 and return the key |
| `Too Many Requests` thrown by `send()` or `sendBatch()` | Per-queue throughput ceiling of 5,000 messages/second exceeded | Batch with `sendBatch()` (100 messages or 256 KB per call), or shard across queues |
| `Storage Limit Exceeded` from `send()` | The queue backlog hit 25 GB — the consumer is not keeping up | Raise consumer concurrency, raise `max_batch_size`, or shed load at the producer |
| A message reappears after your consumer already processed it | At-least-once delivery, as designed | Generate an id at write time and use it as the database primary key or the upstream API's idempotency key |
| Messages arrive out of order | Queues does not preserve publish order | Do not encode order in the queue. Sequence in the payload, or use a Workflow |
| A Workflow re-runs a step that already succeeded | The step name is non-deterministic — built from a timestamp, a random value, or an unordered iteration | Name steps from stable data, traversed in a fixed order. The name is the cache key |
| A cron schedule change does not take effect | Cron Trigger propagation takes up to 15 minutes | Wait. Verify with `npx wrangler tail <worker-name>` rather than redeploying repeatedly |
| Cron Triggers vanished after a deploy | `crons` was set to `[]`, which deletes all of them | Restore the array. Omitting `triggers` entirely leaves existing triggers in place; an empty array removes them |

## Measurements taken from this account

Four measurements, each rerunnable. The account id and the `workers.dev` subdomain are redacted; nothing else is.

**1 — The async surface of this repository.** Every command run from the repository root.

```sh
grep -rIn "waitUntil(" --include="*.js" functions/ workers/sibling/src/ | grep -v node_modules | wc -l
# 30

grep -rIl "waitUntil(" --include="*.js" functions/ workers/sibling/src/ | grep -v node_modules | wc -l
# 9

awk 'NR>=351 && NR<=466' workers/sibling/src/index.js | grep -c "ctx.waitUntil("
# 13   — all inside a single scheduled() handler

grep -rn "^crons" wrangler.toml workers/*/wrangler.toml
# workers/sibling/wrangler.toml:12:crons = ["*/1 * * * *", "0 4 * * *"]

grep -rn "queues.consumers\|\[\[workflows\]\]" wrangler.toml workers/*/wrangler.toml
# workers/sibling/wrangler.toml:63:[[queues.consumers]]
# workers/sibling/wrangler.toml:50:[[workflows]]
# workers/sibling/wrangler.toml:55:[[workflows]]
```

Thirty `waitUntil` call sites across nine files, thirteen of them in one scheduled handler; two cron expressions; one queue consumer; two Workflow classes; zero cron triggers on the Pages project.

**2 — Queues on the account.**

```sh
npx wrangler queues list
```

Three queues: `loop-ingest` (4 producers, 1 consumer), `loop-ingest-dlq` (0 producers, 1 consumer), `loop-tasks` (3 producers, 1 consumer). The DLQ has a consumer attached, which is the only configuration in which a DLQ is more than a four-day holding pen.

**3 — Cron delivery over 21 hours 39 minutes: 1,300 of 1,300 ticks, zero missed.** The `*/1 * * * *` trigger writes one row per tick to a D1 `log` table at `workers/sibling/src/index.js:363`, which makes delivery auditable without the dashboard's 100-event window.

```sh
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT COUNT(*) ticks, MIN(ts) first_ts, MAX(ts) last_ts \
             FROM log WHERE key='sibling.cron' AND ts >= '2026-07-25T00:00:00-07:00'"
```

Returned `ticks: 1300`, `first_ts: 2026-07-25T00:00:07-07:00`, `last_ts: 2026-07-25T21:39:01-07:00`. That span is 1,299 minutes, so 1,300 ticks inclusive of both endpoints is the exact expected count. No tick was dropped.

**4 — Within-minute jitter across the last 1,000 ticks: 99.4% inside 7 seconds.**

```sh
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT substr(ts,18,2) AS sec, COUNT(*) n FROM \
             (SELECT ts FROM log WHERE key='sibling.cron' ORDER BY id DESC LIMIT 1000) \
             GROUP BY sec ORDER BY n DESC"
```

642 ticks landed at `:01`, 352 at `:07`, and 6 were spread across `:08` to `:12`. The worst observed lateness in 1,000 consecutive ticks was 12 seconds. A per-minute cron is punctual enough to schedule against, and nowhere near punctual enough to sequence against.

**5 — Enqueue to consumption: 6 to 8 seconds, median 7.** Method: `POST /api/dispatch {"key":"QUEUE_SEND","body":"NOW|"}` calls `env.TASKS.send()` at `functions/_lib/fn_runners.js:1146` and returns the enqueue timestamp inside the job body. The consumer at `workers/sibling/src/index.js:466` re-dispatches the job, which writes its own invocation receipt. The difference between the two timestamps is the queue's end-to-end latency.

```sh
curl -sS -X POST "https://miscsubjects.com/api/dispatch" \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  --data '{"key":"QUEUE_SEND","body":"NOW|"}'
# {"queued":true,"job":{"key":"NOW","body":"","ts":"2026-07-25T21:41:28-07:00"}}

sleep 25
curl -sS "https://miscsubjects.com/api/invocations?object_id=NOW&limit=1" \
  -H "x-terminal-key: $TERMINAL_KEY"
# ts: 2026-07-25T21:41:34-07:00
```

| Sample | Enqueued | Consumed | Latency |
| --- | --- | --- | --- |
| 1 | 21:40:58 | 21:41:06 | 8 s |
| 2 | 21:41:28 | 21:41:34 | 6 s |
| 3 | 21:41:55 | 21:42:02 | 7 s |
| 4 | 21:42:23 | 21:42:30 | 7 s |

Median 7 seconds, on a queue configured `max_batch_size = 10`, `max_batch_timeout = 5`. Every sample was a single message, so the batch never filled and the 5-second timeout governed every delivery. The 1–3 seconds above the timeout is consumer cold start plus the downstream dispatch.

That number is the honest cost of a queue handoff on an idle queue: your user's request returns in milliseconds, and the work starts about seven seconds later. If that gap is unacceptable, `max_batch_timeout = 0` removes it at the price of one consumer invocation per message.

Context for where these four mechanisms sit in the rest of the platform: [the Cloudflare stack, indexed](/a/cloudflare-os).

## Fresh receipt: 1,000 consecutive minute ticks occupied 1,000 minute slots

Wrangler 4.103.0 and a read-only filesystem harness reran the inventory at `2026-07-26T06:02:38.239Z`. Before querying the live `log` table, the harness read `PRAGMA table_info(log)` and confirmed the columns `id`, `ts`, `trace`, `step`, `parent`, `key`, `type`, `input`, and `output`.

| Fresh check | Result |
| --- | --- |
| JavaScript `waitUntil(` call sites | 30 across 9 files |
| `ctx.waitUntil(` inside the sibling `scheduled()` handler | 13 |
| Cron expressions | `*/1 * * * *` and `0 4 * * *` |
| Workflow bindings | `DELIVER_WF`, `SELFTEST_WF` |
| Queue consumers declared by the sibling | 1 |
| Live queues | `loop-ingest` 4 producers / 1 consumer; `loop-ingest-dlq` 0 / 1; `loop-tasks` 3 / 1 |
| Last 1,000 `sibling.cron` rows | `2026-07-25T06:23:07-07:00` through `2026-07-25T23:02:01-07:00` |
| Minute slots inclusive | 1,000 expected; 1,000 observed |
| Inter-arrival gaps | 990 exactly 60 seconds; range 54–63 seconds |
| Within the first seven seconds of the UTC minute | 996 of 1,000; worst second `:10` |
| D1 read receipt | 1,000 rows read in 2.8599 ms |

Reproduce the repository counts:

```sh
rg -n 'waitUntil\(' functions workers/sibling/src --glob '*.js' | wc -l
rg -l 'waitUntil\(' functions workers/sibling/src --glob '*.js' | wc -l
rg -n '^crons|queues\.consumers|\[\[workflows\]\]' wrangler.toml workers/*/wrangler.toml
npx wrangler queues list
```

Reproduce the live cron sample after reading the schema:

```sh
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "PRAGMA table_info(log)"

npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT id, ts FROM log WHERE key='sibling.cron' ORDER BY id DESC LIMIT 1000"
```

The fresh window does not prove that Cron never misses. It proves the narrower statement: these 1,000 consecutive ledger rows covered exactly 1,000 inclusive minute slots, with all arrivals between second `:00` and `:10`. The earlier 1,300-row window remains above as a separate dated receipt.

## Sources

1. Workers Context API — https://developers.cloudflare.com/workers/runtime-apis/context/
2. Queues delivery guarantees — https://developers.cloudflare.com/queues/reference/delivery-guarantees/
3. Queue batching and retries — https://developers.cloudflare.com/queues/configuration/batching-retries/
4. Dead-letter queues — https://developers.cloudflare.com/queues/configuration/dead-letter-queues/
5. Queues limits — https://developers.cloudflare.com/queues/platform/limits/
6. Queues pricing — https://developers.cloudflare.com/queues/platform/pricing/
7. Rules of Workflows — https://developers.cloudflare.com/workflows/build/rules-of-workflows/
8. Sleeping and retrying Workflows — https://developers.cloudflare.com/workflows/build/sleeping-and-retrying/
9. Workflows pricing — https://developers.cloudflare.com/workflows/reference/pricing/
10. Cron Triggers — https://developers.cloudflare.com/workers/configuration/cron-triggers/
11. Durable Object alarms — https://developers.cloudflare.com/durable-objects/api/alarms/
12. Cloudflare workers-sdk — https://github.com/cloudflare/workers-sdk
13. Working with Cloudflare Workflows — https://thisisacomputer.com/articles/cloudflare-workflows
14. Wrangler Workflows commands — https://developers.cloudflare.com/workers/wrangler/commands/workflows/
15. Queues: add API to immediately dead-letter a specific message — https://github.com/cloudflare/workers-sdk/issues/13816
16. Queue producer binding causes 500 errors on all routes when using `wrangler dev --remote` — https://github.com/cloudflare/workers-sdk/issues/9642
17. Unable to test any binding that requires `--remote` with Queues — https://github.com/cloudflare/workers-sdk/issues/5543
18. OAuth for all — https://news.ycombinator.com/item?id=48679522
19. Building durable workflows on Postgres — https://news.ycombinator.com/item?id=48315400
20. What would a Kubernetes 2.0 look like — https://news.ycombinator.com/item?id=44335222
21. Workflow step Uint8Array triggers SQLITE_TOOBIG locally — https://github.com/cloudflare/workers-sdk/issues/14101
22. Fresh first-party async surface inventory — https://miscsubjects.com/api/articles/cloudflare-os-async
23. Fresh first-party queue inventory — https://miscsubjects.com/api/articles/cloudflare-os-async
24. Fresh first-party 1,000-tick cron receipt — https://miscsubjects.com/api/articles/cloudflare-os-async
25. Fresh first-party cron jitter receipt — https://miscsubjects.com/api/articles/cloudflare-os-async
26. First-party queue handoff timing receipt — https://miscsubjects.com/api/articles/cloudflare-os-async


---

# One missing alarm guard turned a $5.75 workload into $34,895

slug: cloudflare-os-workers · https://miscsubjects.com/a/cloudflare-os-workers · tags: cloudflare, architecture, durable-objects, cloudflare-os · updated 2026-07-26T03:59:33.339Z

Most of a Cloudflare build is one Pages deployment answering one request and forgetting everything between requests. Some jobs cannot be written that way: a schedule with no caller, a counter two clients must not race on, a timer that fires in four hours, a session that remembers what it did last turn. Those need a Worker of their own, and sometimes a Durable Object.

A Durable Object is the expensive answer. It is also the one that produced a $34,895 invoice for a founder with zero users. Read the money section before you write the alarm.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Three things can serve a request, and only one of them remembers

| | Pages Function | Standalone Worker | Durable Object |
| --- | --- | --- | --- |
| Who addresses it | a URL path on the Pages project | its own route, `workers.dev` name, or a service binding | a Worker holds a stub obtained from an id; it has no public address |
| Holds state | no | no | yes: private SQLite storage, plus in-memory state while awake |
| Survives the request | no | no, unless woken by cron, a queue, or email | yes; stays in memory until idle, hibernates, reconstructed on next request |
| How many run at once | as many as there is traffic | as many as there is traffic | exactly one per id, worldwide, single-threaded |
| Billed as | Workers requests + CPU time | Workers requests + CPU time | its own line: requests, wall-clock duration at 128 MB, per-row storage |
| Woken by | an HTTP request | HTTP, `scheduled`, `queue`, `email` | a request from a Worker, or its own alarm |

Rows three and four decide it. If two callers must not interleave on the same piece of state, you need something that exists exactly once and runs one thing at a time. That is a Durable Object, and nothing else on the platform is that.

Storage alone is not a reason. [D1 and KV](/a/cloudflare-os-d1) already store things and cost less to operate. Scheduling alone is not a reason: a cron trigger on a plain Worker is cheaper, and [queues, workflows and cron](/a/cloudflare-os-async) covers which of those three fits.

[[embed:source:s15]]

## A Durable Object is one addressable single-threaded instance, and D1 is one of them

Cloudflare's concepts page: "Each Durable Object has a globally-unique name, which allows you to send requests to a specific object from anywhere in the world," and "Durable Objects are single-threaded and cooperatively multi-tasked, just like code running in a web browser."

[[embed:source:s1]]

Precisely, in the order the pieces matter:

1. **A namespace** is a class you exported and declared in your Wrangler config. `DirectoryDO` is a namespace.
2. **An id** picks one instance inside it. `env.DIRECTORY_DO.idFromName('main')` derives the same id from the same string every time, anywhere on earth.
3. **The instance** for that id exists exactly once. Requests queue; they do not run concurrently.
4. **Its storage** is private to that id. Nothing else reads it except by asking that instance.
5. **Its location** is fixed near wherever it was first created, and does not move.

Point 5 is the cost nobody plans for. A Durable Object is not at the edge the way a Worker is. The community tracker at where.durableobjects.live, which continuously creates and destroys objects to sample placement, reported Durable Objects available in **10.8% of Cloudflare points of presence** on the day this page was measured. Your Worker runs next to the reader; the object it talks to may not.

[[embed:source:s17]]

[[embed:source:s18]]

The Workers architect is blunter than the documentation about what a Durable Object is relative to D1:

> I'll let you in on a sort of dirty secret: It's almost always better to use Durable Objects storage, rather than D1. Even if you only want a single global database, it's better to implement that as a singleton Durable Object, than by using D1. Because that's all D1 itself actually is: a singleton Durable Object that exposes an API to its SQLite database. It's just a wrapper.

[[embed:source:s7]]

Follow the reasoning, not the authority. The argument is about round trips: with raw Durable Objects your query code runs on the same machine as the SQLite file, so a chain of queries is local. With D1 the Worker crosses the long-haul network per hop. One query per request and the two are equivalent. Two or more in series and the Durable Object wins by however many round trips it removes.

He grants D1 one advantage, and it is real: "D1's read replica support still isn't exposed in a way that you can use it in raw Durable Objects, so if you are using that, it's a legitimate advantage to D1."

So: read-heavy, globally distributed reads of the same data, no serialisation requirement means D1 with replicas. Write-serialised, per-entity, chained queries mean Durable Object. That is the whole split.

The counterweight, from a reply on the same thread, is that the advice is not reaching the tools people build with: "Pages were slow due to the multiple round trips to storage on each page since Claude Code used D1. Despite repeated prompting Claude Code had no suggestions for how to improve within the CF platform."

[[embed:source:s8]]

## $34,895 with zero users: an alarm that rescheduled itself on every wake-up

The most useful thing on this page. A pre-launch solo founder published the whole postmortem in April 2026.

> My DO agent's onStart() handler called this.ctx.storage.setAlarm() on every wake-up without checking whether an alarm was already scheduled.

[[embed:source:s9]]

The mechanism, step by step:

1. `onStart()` runs every time the object wakes, including after hibernation and including after the alarm handler woke it. The constructor runs *before* the alarm handler, so an unconditional `setAlarm()` in startup code re-arms on every tick.
2. Each preview deployment gets its own Durable Object instances. Sixty-plus previews meant sixty-plus independent copies of the loop, none of them on the production dashboard the founder was watching.
3. The loop peaked at roughly **930 billion row reads per day** on 4–5 April.
4. It ran 3 April to 11 April before it was found. The invoice was **$34,895**, due 15 April, with zero users.
5. Nothing warned: "Cloudflare's Workers Usage Notifications only monitors CPU time. Not Durable Object row reads or writes. There is also no hard spending cap for DO operations available in the dashboard or Wrangler config."

The published fix, verbatim from the post:

```js
// Before (dangerous)
async onStart() {
  await this.ctx.storage.setAlarm(Date.now() + 60_000)
}

// After (safe)
async onStart() {
  const existing = await this.ctx.storage.getAlarm()
  if (!existing) {
    await this.ctx.storage.setAlarm(Date.now() + 60_000)
  }
}
```

Cloudflare documents the trap in a callout most people never reach: "If you wish to call setAlarm inside the constructor of a Durable Object, ensure that you are first checking whether an alarm has already been set. This is due to the fact that, if the Durable Object wakes up after being inactive, the constructor is invoked before the alarm handler."

[[embed:source:s4]]

Four rules follow, in the order to apply them:

1. **Never call `setAlarm()` without reading `getAlarm()` first**, anywhere that can run more than once: constructor, `onStart`, `blockConcurrencyWhile`. All of them run on every wake.
2. **Bound the frequency and the number of ticks.** An alarm that re-arms forever is an infinite loop with a billing meter. Give it a step cap and a terminal state.
3. **Strip Durable Object bindings from preview environments**, or accept that every preview is production as far as the meter is concerned. A preview creates real objects with real storage on the real bill.
4. **Put a budget alert on the account, because the platform will not.** The usage notification you already have watches CPU time, not row operations.

## Alarms fail in two documented ways, and both are silent

The API reference states the contract: "Each Durable Object is able to schedule a single alarm at a time by calling setAlarm()," and "The alarm() handler has guaranteed at-least-once execution and will be retried upon failure using exponential backoff, starting at 2 second delays for up to 6 retries." Six retries is the entire budget.

[[embed:source:s2]]

**One: alarms stop after a code reload in local development.** Filed against `cloudflare/workerd`:

> The alarm triggers as expected, but as soon as the code has changes and the worker reloads, then the alarm stops triggerring.

[[embed:source:s10]]

The alarm is still visible via `ctx.storage.getAlarm()`; it simply never fires again until the dev server restarts. Practical consequence: "my alarm stopped" in `wrangler dev` is not evidence of a bug in your code. Restart the dev server before debugging anything.

**Two: one past timestamp in storage deadlocks scheduling forever.** Filed against `opennextjs/opennextjs-cloudflare`:

> If `nextAlarm` is a past timestamp, no new alarm is set, creating a deadlock where `alarm()` never fires and tags accumulate in the database.

[[embed:source:s11]]

The shape is a scheduling guard that reads the stored alarm, sees *a* value, and skips setting a new one. If the previous handler died after storing a timestamp but before clearing it, that stale past value is permanent, and every later call reads it and does nothing. One transient failure buys a permanent silent outage.

A guard that survives both cases has to check not just that an alarm exists but that it is still in the future:

```js
const MIN_INTERVAL_MS = 30_000;

async scheduleNext(delayMs) {
  const runAt = Date.now() + Math.max(delayMs, MIN_INTERVAL_MS);
  const existing = await this.ctx.storage.getAlarm();
  // Non-null is not enough: a past timestamp means nothing is scheduled.
  if (existing !== null && existing > Date.now()) return;
  await this.ctx.storage.setAlarm(runAt);
}

async alarm() {
  try {
    await this.doWork();
  } catch (err) {
    // Six retries is the platform budget. Re-arm inside the handler so a long
    // downstream outage cannot exhaust it and leave the object unscheduled.
    await this.ctx.storage.setAlarm(Date.now() + 60_000);
    throw err;
  }
}
```

The `catch` block is Cloudflare's own recommendation: "it's recommended to catch any exceptions inside your alarm() handler and schedule a new alarm before returning if you want to make sure your alarm handler will be retried indefinitely."

## WebSockets bill for wall-clock time, and the documented fix is a rewrite

A Durable Object is the natural place to terminate WebSockets because one object holds every connection for one room. The billing consequence is stated in the pricing footnotes: "Calling accept() on a WebSocket in an Object will incur duration charges for the entire time the WebSocket is connected."

Duration is charged at 128 MB regardless of actual use. One idle socket held open for a month is 2,592,000 s × 128 MB ÷ 1 GB = 331,776 GB-s, most of the 400,000 GB-s monthly allowance consumed by one connection doing nothing.

The Hibernation WebSocket API exists for this. Cloudflare marks it recommended and describes it as the one that "allows the Durable Object to hibernate without disconnecting clients when idle." Their own worked example: 100 objects × 100 sockets each, one message per minute, costs **$138.65 per month** on plain WebSockets and **$10.00 per month** with hibernation, because the object is billed for the 10 ms per message rather than the whole month.

[[embed:source:s5]]

The gap between the documented fix and the shipped fix is where people get stuck:

> I have a Cloudflare Worker that uses Durable Objects and WebSocket. However, the costs of WebSocket are high, so I decided to implement the Websocket Hibernation API

[[embed:source:s12]]

That poster hit the cost, read the recommendation, and could not get the hibernation code working at all. Both halves are true: hibernation is the right answer, and it is a rewrite rather than a flag. `acceptWebSocket()` replaces `accept()`, event listeners become `webSocketMessage` / `webSocketClose` / `webSocketError` methods on the class, and per-connection state must move into `serializeAttachment()` because the object is rebuilt from its constructor after every hibernation.

## What it costs, at today's published rates

| Line | Free plan | Paid plan | Notes |
| --- | --- | --- | --- |
| Durable Object requests | 100,000 / day | 1 million / month, then **$0.15 / million** | HTTP requests, RPC sessions, WebSocket messages and **alarm invocations** all count |
| Durable Object duration | 13,000 GB-s / day | 400,000 GB-s / month, then **$12.50 / million GB-s** | Wall clock while active or ineligible for hibernation, billed at 128 MB whatever you use |
| SQLite rows read | 5 million / day | first 25 billion / month, then **$0.001 / million** | The line the $34,895 invoice ran up |
| SQLite rows written | 100,000 / day | first 50 million / month, then **$1.00 / million** | A thousand times the read rate. Each `setAlarm` is a write |
| SQLite stored data | 5 GB total | 5 GB-month, then **$0.20 / GB-month** | An empty SQLite database is about 12 KB |
| Incoming WebSocket messages | — | billed **20:1** as requests | 100 incoming messages bill as 5 requests |
| Plain Worker requests | 100,000 / day | 10 million / month, then **$0.30 / million** | Separate from Durable Object requests |
| Plain Worker CPU time | 10 ms / invocation | 30 million CPU-ms / month, then **$0.02 / million CPU-ms** | Time waiting on I/O is not billed |
| Account minimum | — | **$5 / month** | Applies whatever the usage |

[[embed:source:s3]]

[[embed:source:s6]]

Arithmetic for a stated workload: one Durable Object per user session, 10,000 sessions a day, 20 requests each, 200 ms of active wall clock per request, three row reads and one row write per request:

- Requests: 10,000 × 20 × 30 = 6,000,000 / month. (6,000,000 − 1,000,000) × $0.15 ÷ 1,000,000 = **$0.75**
- Duration: 6,000,000 × 0.2 s = 1,200,000 s × 128 MB ÷ 1 GB = 153,600 GB-s, under the 400,000 allowance = **$0.00**
- Rows read: 18,000,000 / month against 25 billion included = **$0.00**
- Rows written: 6,000,000 / month against 50 million included = **$0.00**
- Account minimum: **$5.00**
- **Total: $5.75 / month.**

Now the same rates against the runaway. 930 billion row reads in one day, priced past the monthly allowance at $0.001 per million, is **$930 for that day's reads alone**. The published invoice was $34,895 over eight days and the postmortem does not break out writes. Writes cost $1.00 per million, a thousand times the read rate, and every `setAlarm` is a write. A loop that writes as well as reads reaches five figures in days. The distance between $5.75 and $34,895 is one missing `getAlarm()`.

## The case for and against, from people running them

The strongest positive is scale with a cost claim attached:

> We serve multi million MAU on sqlite orchestrated through durable objects. It's not the most complex thing in the world but it goes further than CRUD. It costs us such a small amount of money for what it does.

[[embed:source:s13]]

The same commenter says a Postgres cluster was the expensive thing this replaced. Note what makes it work: many small objects, each holding one tenant's data, none holding a socket open. The bill is dominated by requests, and requests are $0.15 per million.

The second positive comes with a boundary the author draws himself, which is the more useful part:

> DO alarms handle the time-based stuff (fleet arrivals, combat resolution, resource ticks) so there's no persistent connection cost. so far costs have been negligible

[[embed:source:s14]]

And immediately after, unprompted: "websockets + stateful server would be the right call for anything realtime. for tick-based strategy with hour-long timers, DOs feel like the cleanest fit."

That is the honest rule. Alarms are cheap because the object sleeps between them. WebSockets are expensive because the object cannot sleep. A game whose actions resolve over hours pays almost nothing; the same game in real time pays duration for every connected second.

Against, at the same scale: the billing blast radius has no ceiling. No hard spending cap for Durable Object operations exists in the dashboard or in Wrangler, the usage notification watches CPU rather than rows, and previews are indistinguishable from production on the meter. Both things hold at once. Choose Durable Objects for what they are good at, and put your own kill switch on the account, because the platform does not ship one.

## Seven Workers sit outside the main deployment, each for a stated reason

This application runs one Pages project with 387 handlers, covered in [Functions as the request layer](/a/cloudflare-os-functions), plus seven Wrangler configurations for standalone Workers.

| Worker | Config | Why it cannot be a Pages Function |
| --- | --- | --- |
| `loop-safe-sibling` | `workers/sibling/wrangler.toml` | Cron triggers `*/1 * * * *` and `0 4 * * *`, a queue consumer on `loop-tasks`, an `email` handler, two Workflow classes and two Durable Object classes. A Pages project has no timer, no queue consumer and no inbound email handler |
| `loop-safe-directory-do` | `workers/directory-do/wrangler.toml` | Hosts the `DirectoryDO` class. Durable Object classes must live in a Worker script; Pages binds to them by `script_name` and cannot define them |
| `loop-safe-storage` | `workers/storage/wrangler.toml` | `workers_dev = false`, reachable only through the `STORE` service binding. Keeps bulk R2 traffic and its D1 index off the request path and off the public surface |
| `miscsubjects-mcp` | `workers/mcp-server/wrangler.jsonc` | A different protocol for a different kind of client, with its own `MiscsubjectsMCP` Durable Object per session, versioned separately from the site |
| `loop-meta-bridge` | `workers/meta-bridge/wrangler.toml` | `workers_dev = false`, no public route. Binds three vendor secrets from Secrets Store *by reference*, so no copy of the token exists in the Pages project |
| `oip-peer` | `workers/oip-peer/wrangler.toml` | The second federation node. A separate registrable domain is the point; a peer boundary that shares a deployment is not a peer boundary |
| `miscsubjects-robots` | `workers/robots-fix/wrangler.toml` | One route, `miscsubjects.com/robots.txt`, one file. No reason to redeploy 387 handlers to change one text file |

Four Durable Object classes are declared across those configs. Counted directly:

```
$ grep -rn "^export class" workers/*/src/index.*
workers/directory-do/src/index.js:14:export class DirectoryDO {
workers/mcp-server/src/index.ts:15:export class MiscsubjectsMCP extends McpAgent<Env> {
workers/sibling/src/index.js:35:export class DeliverWorkflow extends WorkflowEntrypoint {
workers/sibling/src/index.js:65:export class SelfTestWorkflow extends WorkflowEntrypoint {
workers/sibling/src/index.js:114:export class ExpertDO {
workers/sibling/src/index.js:139:export class AgentDO {
```

[[embed:source:s19]]

## The binding-order failure: a deploy that errors on a binding to a script never uploaded

A Durable Object binding in a Pages project names another Worker by script name:

```toml
[[durable_objects.bindings]]
name = "DIRECTORY_DO"
class_name = "DirectoryDO"
script_name = "loop-safe-directory-do"
```

**Symptom.** The Pages deploy fails at the binding step, or succeeds and then every request touching the binding returns a 500. It reads like a malformed configuration file. The TOML is correct.

**Cause.** `script_name` is a *reference* to a Worker that must already exist on the account. Deploy Pages first and there is nothing for the binding to point at. Same for `[[services]]`: this project binds `STORE` to `loop-safe-storage` and `META_BRIDGE` to `loop-meta-bridge`, both references, not definitions.

**Fix.** A fixed deploy order, recorded in the config file itself so nobody has to remember it:

```
# 1. every referenced Worker first
cd workers/directory-do && npx wrangler deploy
cd ../storage           && npx wrangler deploy
cd ../meta-bridge       && npx wrangler deploy
# 2. schema, if the deploy needs it
npx wrangler d1 execute loop-content-spine --remote --file=migrations/<file>.sql
# 3. the Pages project last
npx wrangler pages deploy public
```

The handler in front of the binding names the failure instead of throwing a generic 500, which turns a lost afternoon into a ten-second diagnosis. See `functions/api/durable/[[path]].js`, lines 28–32:

```js
if (!env.DIRECTORY_DO) {
  return new Response(JSON.stringify({ ok: false, error: 'DIRECTORY_DO binding missing — deploy loop-safe-directory-do and add the Pages binding' }), {
    status: 500, headers: { 'content-type': 'application/json' },
  });
}
```

Do the same for every binding you take. Three lines that name the missing Worker pay for themselves the first time.

There is a quieter version of the same class of bug: two copies of one binding drifting apart. This build had one vendor token bound by reference in `loop-meta-bridge` and a second copy held as a Pages environment variable. The bridge copy stayed fresh; the Pages copy expired, and everything reading the Pages copy failed while everything reading the bridge worked. Bind by reference from one place, and keep no second copy.

## What a real Durable Object in this build does, read from the source

`workers/directory-do/src/index.js` is 102 lines and shows the whole shape of a minimal Durable Object.

**Lines 14–27: schema on construction.** The class takes `state` and `env`, grabs `state.storage.sql`, and wraps its `CREATE TABLE IF NOT EXISTS` calls in `state.blockConcurrencyWhile()`. That wrapper is the safety: no request is served until the callback resolves, so no handler can see a half-built schema. Two tables exist: `slugs`, a registry of declared internal addresses, and `intents`, an append-only log of every mutation.

**Lines 54–66: a write that is safe because there is only one writer.** `slug.register` reads the existing row to preserve its original `declared_at`, writes with `INSERT OR REPLACE`, then appends to `intents`. Those reads and writes cannot interleave, because exactly one instance exists for the id `main` and it is single-threaded. Written against D1 the same sequence is a read-modify-write race needing a transaction or a version column.

**Lines 85–95: how a caller reaches it.** `env.DIRECTORY_DO.idFromName('main')` derives the id, `.get(id)` returns a stub, `stub.fetch()` sends a request. The URL passed to the stub is a fabricated `https://do/`; the hostname is meaningless, only path and query reach the object.

[[embed:source:s16]]

Contrast `AgentDO` in `workers/sibling/src/index.js`, lines 139–200: an alarm-driven loop, the risky shape. It survives the $34,895 failure mode for four nameable reasons.

- `setAlarm()` is called in `spawn` (once per agent), in `send` / `resume` only when the status is not already `running`, and at the end of `alarm()`, never in the constructor.
- `alarm()` returns immediately if `status !== 'running'`, so a killed or completed agent stops re-arming.
- `maxSteps` is clamped to at most 40 with `Math.min(Math.max(parseInt(b.maxSteps || '12', 10) || 12, 1), 40)`, and `alarm()` sets `status = 'done'` once `steps >= maxSteps`. The loop is bounded by construction.
- `kill` calls `this.state.storage.deleteAlarm()`.

That is what "bound the alarm" means in code. It is still not fully defended: a `setAlarm` added to the constructor tomorrow reintroduces the bug. That is why the `getAlarm()` guard above belongs in any new class.

## Measured here: the Durable Object hop is not the latency you think it is

Three first-party measurements, with the commands, so they can be rerun.

**1: every Worker on the account and when it last shipped.** From the repository root, wrangler 4.103.0:

```
$ for w in loop-safe-sibling loop-safe-directory-do loop-safe-storage \
           miscsubjects-mcp miscsubjects-robots loop-meta-bridge oip-peer; do
    printf "%-28s " "$w"
    npx wrangler deployments list --name "$w" | grep -m1 "^Created:"
  done
```

| Worker | Latest deployment created |
| --- | --- |
| `loop-safe-sibling` | 2026-07-03T03:32:24Z |
| `loop-safe-directory-do` | 2026-06-13T23:24:17Z |
| `loop-safe-storage` | 2026-06-16T18:59:19Z |
| `miscsubjects-mcp` | 2026-06-20T19:19:34Z |
| `miscsubjects-robots` | 2026-07-01T08:30:03Z |
| `loop-meta-bridge` | 2026-07-12T03:13:16Z |
| `oip-peer` | 2026-07-15T20:44:30Z |

The Durable Object host has not been redeployed since June and does not need to be — a bound Durable Object Worker changes only when its class changes.

**2 — round-trip latency, and a measurement error corrected in public.** Ten sequential requests to `/robots.txt` (a standalone Worker, no bindings) gave a 164 ms median; ten to `/api/durable/ping` (a Pages Function calling a Durable Object stub) gave 643 ms. That looks like a 4x penalty for the Durable Object hop. It is not. The two blocks ran minutes apart and the difference is client network drift. Rerun interleaved — one request to each per iteration, twelve iterations — and it disappears:

```
$ for i in $(seq 1 12); do
    a=$(curl -s -o /dev/null -w "%{time_total}" https://miscsubjects.com/robots.txt)
    b=$(curl -s -o /dev/null -w "%{time_total}" https://miscsubjects.com/api/map)
    c=$(curl -s -o /dev/null -w "%{time_total}" https://miscsubjects.com/api/durable/ping)
    echo "$a $b $c"
  done
```

| Endpoint | n | min | median | p90 | max |
| --- | --- | --- | --- | --- | --- |
| `/robots.txt` — standalone Worker, no bindings | 12 | 108 ms | 272 ms | 673 ms | 1294 ms |
| `/api/map` — Pages Function, no Durable Object | 12 | 159 ms | 237 ms | 585 ms | 1301 ms |
| `/api/durable/ping` — Pages Function → Durable Object | 12 | 154 ms | 276 ms | 381 ms | 748 ms |

The three are indistinguishable at this sample size, and the Durable Object path has the *tightest* tail. Honest conclusion: on this deployment, from this client, the Durable Object hop is buried inside ordinary network variance. The method matters more than the number — measure interleaved, or publish your own jitter as a platform finding.

**3 — the object's real state, read live.** The Pages front door at `/api/durable/*` forwards to the stub, so a plain GET reads what the object holds:

```
$ curl -s https://miscsubjects.com/api/durable/ping
{"ok":true,"do":"DirectoryDO","id":"61f9320db3f158babd018d01b56ca7db4434be41d738fc4dbc294ef21d45d883","ts":"2026-07-26T04:40:18.284Z"}

$ curl -s https://miscsubjects.com/api/durable/slug.list | python3 -c "import json,sys; print(json.load(sys.stdin)['count'])"
54
```

Fifty-four slugs in the registry; the `intents` log returns 157 rows against its `LIMIT 200`. The `id` is the 64-hex object id derived from the name `main` — the same string every time, from anywhere, which is the addressing property the whole design rests on.

[[embed:source:s20]]

## Which one to reach for

| The job | Choose | Why |
| --- | --- | --- |
| Answer an HTTP request for the site | Pages Function | Already deployed with the site, shares its bindings, no extra address to maintain |
| Run something on a timer | standalone Worker with a cron trigger | A Pages project has no timer, and nothing about a schedule needs state |
| Drain a queue | standalone Worker with a queue consumer | Pages projects can produce to a queue but cannot consume from one |
| Serve one endpoint that changes on a different cadence than the site | standalone Worker on a route | A deploy boundary is a blast-radius boundary |
| Serialise writes to one entity — a counter, a room, a document | Durable Object | The only thing on the platform that exists exactly once and runs one thing at a time |
| Hold a session's working memory across many calls | Durable Object | In-memory state survives between requests; storage survives hibernation |
| Chain three or more queries for one request | Durable Object with SQLite storage | Query code runs on the same machine as the file, so the chain is local |
| Serve the same read-heavy data globally | D1 with read replicas | The one advantage the architect grants D1 over raw Durable Objects |
| Real-time bidirectional messaging | Durable Object with the **Hibernation** WebSocket API | Duration billing on a plain `accept()` socket is the most expensive mistake available |
| A long multi-step job that must survive failure | a Workflow, not a Durable Object | Covered in [queues, workflows and cron](/a/cloudflare-os-async) |

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| Pages deploy errors on a binding, or every request touching it 500s | `script_name` / `service` points at a Worker not yet uploaded | Deploy the referenced Workers first, Pages last. Add an `if (!env.BINDING)` branch that says so |
| `{"ok":false,"error":"DIRECTORY_DO binding missing — deploy loop-safe-directory-do and add the Pages binding"}` | The Durable Object host Worker is absent from the account or the environment | `cd workers/directory-do && npx wrangler deploy`, then redeploy Pages |
| Row reads climb with no traffic | `setAlarm()` called unconditionally somewhere that runs on every wake | Guard with `getAlarm()`, and verify the stored value is in the future, not merely non-null |
| The bill is large and the production dashboard looks quiet | Preview deployments created their own Durable Object instances | Strip Durable Object bindings from preview environments, or count previews as production |
| A background job silently stopped and never restarts | A failed handler left a past timestamp; the scheduling guard reads it as "already scheduled" | Treat `existing <= Date.now()` as unscheduled and set a new alarm |
| Alarm fires once in `wrangler dev`, then never again after an edit | Hot reload drops the alarm while `getAlarm()` still reports it — `workerd` issue 3566 | Restart the dev server. Do not debug your code first |
| Alarm stops after roughly six failures | Retry budget exhausted — six retries, exponential backoff from 2 s | Catch inside `alarm()`, set a new alarm, then rethrow |
| WebSocket bill dominated by duration, not messages | `accept()` keeps the object in memory for the whole connection | Move to `acceptWebSocket()` plus `webSocketMessage` / `webSocketClose` handlers and `serializeAttachment()` |
| A Durable Object stays billed with no requests arriving | An outbound `connect()` or WebSocket holds it in memory for up to 15 minutes per connection | Close outbound connections when the work is done |
| Two copies of one secret, one expired | A binding duplicated as an environment variable instead of referenced from one place | Bind by reference from a single Worker and service-bind to it |

Every binding this build declares, and what each costs, is on the [Cloudflare OS index](/a/cloudflare-os).


## Sources

1. What are Durable Objects? — https://developers.cloudflare.com/durable-objects/concepts/what-are-durable-objects/
2. Durable Object lifecycle — https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/
3. Durable Objects pricing — https://developers.cloudflare.com/durable-objects/platform/pricing/
4. Durable Object alarms API — https://developers.cloudflare.com/durable-objects/api/alarms/
5. Use WebSockets with Durable Objects — https://developers.cloudflare.com/durable-objects/best-practices/websockets/
6. Cloudflare Workers pricing — https://developers.cloudflare.com/workers/platform/pricing/
7. Temporary Cloudflare accounts for AI agents — https://news.ycombinator.com/item?id=48611834
8. Temporary Cloudflare accounts for AI agents — https://news.ycombinator.com/item?id=48611834
9. Durable Object alarm loop: $34k in 8 days, zero users, no platform warning — https://news.ycombinator.com/item?id=47787042
10. 🐛 BUG: Durable Object Alarms not triggering after a code reload — https://github.com/cloudflare/workerd/issues/3566
11. [BUG] Durable Objects alarm not firing due to stale past alarms remaining in storage — https://github.com/opennextjs/opennextjs-cloudflare/issues/929
12. Trying to use Websocket Hibernation Api — https://stackoverflow.com/questions/79336461/trying-to-use-websocket-hibernation-api
13. SQLite Is All You Need — https://news.ycombinator.com/item?id=48946048
14. Show HN: I rebuilt a 2000s browser strategy game on Cloudflare's edge — https://news.ycombinator.com/item?id=47785298
15. Cron Triggers — https://developers.cloudflare.com/workers/configuration/cron-triggers/
16. Access Durable Object storage — https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/
17. Where Durable Objects Live — https://where.durableobjects.live/
18. Durable Objects: Easy, Fast, Correct — Choose three — https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/
19. Production Worker and Durable Object inventory — https://miscsubjects.com/api/durable/slug.list
20. Live DirectoryDO response — https://miscsubjects.com/api/durable/ping


---

# Pages Functions compiles 224 route files into one 1.35 MB Worker

slug: cloudflare-os-functions · https://miscsubjects.com/a/cloudflare-os-functions · tags: cloudflare, architecture, workers, cloudflare-os, pages-functions, routing, middleware, wrangler, deployment · updated 2026-07-26T03:59:30.563Z

A Pages Function is a JavaScript file inside a directory called `functions/`. Cloudflare compiles every one of those files into a single Worker script and runs it at the edge in front of the site's static files. There is no route table to write: the path of the file *is* the URL it answers.

That is fine at ten files. This application has 387 modules under `functions/`, of which 223 are route files exporting 270 request handlers. At that size four things start to matter: which file wins when two could match, whether a request touches the Worker at all, how big the compiled bundle has grown against a hard ceiling, and what you can see when a handler fails.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The file tree is the router, and there is no other route table

Cloudflare's routing document states it in one line: your `/functions` directory structure determines the routes. Real paths from this repository:

| File | URL it answers |
| --- | --- |
| `functions/index.js` | `/` |
| `functions/latest.js` | `/latest` |
| `functions/a/[slug].js` | `/a/cloudflare-os-functions`, `/a/anything` |
| `functions/api/articles/[[path]].js` | `/api/articles` and every path beneath it |
| `functions/api/articles/design-law/skill.js` | `/api/articles/design-law/skill` |
| `functions/_middleware.js` | every request, before any of the above |

Three bracket forms, and they behave differently:

| Form | Captures | `context.params` type | Example in this repo |
| --- | --- | --- | --- |
| `name.js` | exactly that path | — | `functions/latest.js` |
| `[param].js` | exactly one path segment | string | `functions/a/[slug].js` |
| `[[path]].js` | one segment or many | array of strings | `functions/api/articles/[[path]].js` |

`functions/a/[slug].js` answers `/a/thing` and **not** `/a/thing/extra`. The catch-all answers `/api/articles/x/y/z` and receives `context.params.path` as `["x","y","z"]`. If no Function matches, Pages falls through to a static file with that name. Trailing slashes are ignored.

## A handler file exports up to eight functions, one per HTTP method

The API reference is explicit about the interaction between them: `onRequest` is called *unless* a more specific `onRequestVerb` is exported. Export both `onRequest` and `onRequestGet` and a GET only ever reaches `onRequestGet`. The eight names are `onRequest`, `onRequestGet`, `onRequestPost`, `onRequestPut`, `onRequestPatch`, `onRequestDelete`, `onRequestHead`, `onRequestOptions`. Counted across this repository today:

| Export | Count |
| --- | --- |
| `onRequestGet` | 169 |
| `onRequestPost` | 51 |
| `onRequest` | 28 |
| `onRequestOptions` | 7 |
| `onRequestPut` | 6 |
| `onRequestDelete` | 6 |
| `onRequestPatch` | 3 |
| **Total handler exports** | **270** |

```bash
grep -rhoE '^export (async )?(function|const) onRequest[A-Za-z]*' functions --include='*.js' \
  | grep -oE 'onRequest[A-Za-z]*' | sort | uniq -c | sort -rn
```

Every handler receives one argument, the `EventContext`. Its useful members: `request`, `env` (your bindings), `params` (the bracket captures), `next()` (pass through to the next Function or to the static asset server), `waitUntil()` (finish work after the response is sent) and `passThroughOnException()`.

## One middleware file sits in front of 100 percent of traffic

`_middleware.js` is the only filename Pages treats as middleware. At `functions/_middleware.js` it runs in front of the entire application, static files included. At `functions/users/_middleware.js` it runs only for requests under `/users`. It calls `context.next()` to hand the request onward and can rewrite what comes back. This application has exactly one, 946 lines, exporting a single `onRequest`. Its order is a property of that file, not of Pages:

1. Edge cache lookup for public, cacheable reads.
2. Lean-body branch: a crawler or model fetcher gets the same content from a KV snapshot with the stylesheet stripped, marked private so no shared cache can hand it to a person.
3. `adminGate(context)`, before any handler sees the request.
4. `machineDataGuard`: a browser navigating to a raw `/api/` URL gets a readable page, a machine gets JSON.
5. `?bundle=1` redirects to the object-folder endpoint.
6. `context.next()` into the matched handler.
7. Category header on every response, injections, cache write.

Two things to know before putting this much into middleware. You may export an array instead of a function — `export const onRequest = [errorHandling, authentication]` — and entries run in order, so the first can catch throws from the rest. And CPU spent in middleware is charged against the same per-request budget as the handler, because they are the same Worker invocation.

## An optional catch-all can beat a static file with the same name

Cloudflare's routing document says: *"More specific routes (routes with fewer wildcards) take precedence over less specific routes."* Read plainly, a real filename beats `[[path]].js`. This repository does not trust it. At `functions/api/articles/[[path]].js` line 788:

```js
// Canonical Knowledge-Action subresources share the existing article router.
// Keep this dispatch explicit because the optional catch-all route can outrank a
// same-name static function for `/skill` on Pages.
```

The layout that produces the ambiguity:

```
functions/api/articles/
├── [[path]].js                  ← matches /api/articles/**
└── design-law/
    ├── index.js
    └── skill.js                 ← matches /api/articles/design-law/skill exactly
```

Both can serve `GET /api/articles/design-law/skill`. The static `skill.js` exports `onRequestGet` and returns markdown; the catch-all carries a branch at the top of its `handle()` returning byte-identical markdown for the same path. The response proves nothing about which one ran:

```
$ curl -sI https://miscsubjects.com/api/articles/design-law/skill
HTTP/2 200
content-type: text/markdown; charset=utf-8
content-disposition: inline; filename="SKILL.md"
```

That symmetry is the defence: because both return the same bytes, either can win the route and the site behaves. Delete the branch in the catch-all and you are betting the endpoint on precedence resolving your way.

The rule: **specificity is scored, not guaranteed by having a real filename.** When a catch-all and a static file overlap, either make the catch-all handle the path too, or move the static file out of its subtree.

## `_routes.json` decides whether a request costs anything

Once a `functions/` directory exists, every request invokes the Worker by default, including requests for fonts and images a Function was never going to touch. `_routes.json` takes routes back. It lives in the build output directory — `public/` here, not the repository root — and has three keys: `version`, `include`, `exclude`. Exclude always wins over include. This project's file, complete:

```json
{
  "version": 1,
  "description": "Tell Cloudflare Pages NOT to handle these paths — they belong to the miscsubjects-admin Worker via its route bindings.",
  "include": ["/*"],
  "exclude": [
    "/site.html", "/site", "/showcase", "/showcase.html",
    "/widgets", "/widgets.html", "/font/*", "/control", "/spec",
    "/edit/*", "/article/*", "/condition/*",
    "/import-export", "/import", "/export"
  ]
}
```

One include rule and fifteen exclude rules: sixteen of the hundred allowed. The file's own limits are a trap — at least one include rule is required, no more than 100 rules combined, none longer than 100 characters. A wildcard matches any number of segments, so `/font/*` covers everything below `/font/`.

Two reasons the exclude list matters, and only one is money. Excluding `/font/*` means a font request never enters this Worker's CPU budget and never counts as an invocation; static requests on Pages are free and unlimited, Function invocations bill at the Workers rate. The second is correctness: those paths are claimed by a separate Worker bound to those routes, and leaving them included would mean this Worker answered first.

## `HANDOFF_CLAUDE_CODE.md` says 387 handlers; 213 files actually handle a request

Re-counted today the total is still 387 — but "handlers" flatters it. That figure counts every JavaScript module under `functions/`, and 164 are shared library code that never answers a URL.

```bash
find functions -name '*.js' -not -name '*.test.*' | wc -l                                  # 387
find functions -name '*.js' -not -path 'functions/_lib/*' -not -name '*.test.*' | wc -l    # 223
find functions/_lib -name '*.js' -not -name '*.test.*' | wc -l                             # 164
grep -rlE '^export (async )?(function|const) onRequest' functions --include='*.js' | wc -l # 213
```

| Thing counted | Today |
| --- | --- |
| Modules under `functions/` | 387 |
| Route files (excluding `_lib/`) | 223 |
| Shared library modules in `_lib/` | 164 |
| Route files exporting a handler | 213 |
| Handler exports across those files | 270 |
| `_middleware.js` files | 1 |
| `[param].js` single-segment routes | 19 |
| `[[path]].js` catch-alls | 32 |

Ten route files export no handler — `functions/a/writing-law.js`, `functions/api/design-law.js` and eight others export helpers that neighbouring route files import. They still compile into the bundle. Where the routes live: `functions/api/` 124, `functions/admin/` 42, `functions/` root 26, `functions/a/` 7, `functions/oip/` 3, `functions/content/` and `functions/.well-known/` 2 each, 17 further single-file directories.

```bash
find functions -name '*.js' -not -path 'functions/_lib/*' -not -name '*.test.*' \
  | awk -F/ '{ if (NF==2) print "functions/ (root)"; else print "functions/"$2"/" }' \
  | sort | uniq -c | sort -rn
```

## The limits, with the numbers the documentation carries today

Fetched 25 July 2026 from Cloudflare's Pages limits page (last updated 16 July 2026) and Workers limits page. Pages Functions are Workers, so compute and size come from the Workers page, asset limits from the Pages page.

| Limit | Workers Free | Workers Paid | Source page |
| --- | --- | --- | --- |
| Compiled script size, **after gzip** | 3 MB | 10 MB | Workers limits |
| Compiled script size, before compression | 64 MB | 64 MB | Workers limits |
| Worker startup time | 1 second | 1 second | Workers limits |
| CPU time per request | 10 ms | 5 min (default 30 s) | Workers limits |
| Memory per isolate | 128 MB | 128 MB | Workers limits |
| Subrequests per invocation | 50 | 10,000 | Workers limits |
| Subrequests to internal services | 1,000 | 10,000 default | Workers limits |
| Files per Pages site | 20,000 | 100,000 | Pages limits |
| Size of a single site asset | 25 MiB | 25 MiB | Pages limits |
| Git-integration builds per month | 500 | 5,000 (Pro) | Pages limits |
| `_routes.json` rules, include + exclude | 100 | 100 | Pages routing |
| Custom domains per project | 100 | 250 (Pro) | Pages limits |

Four of these bite differently than the table suggests.

**Only the gzipped number counts.** OpenNext's Cloudflare troubleshooting page is blunt: *"When deploying your Worker, wrangler will show both the original and compressed sizes. Only the latter (gzipped size) matters for these limits."* A 5 MB `index.js` is not automatically a problem.

**CPU time is not wall time.** Waiting on a `fetch()`, a KV read or a D1 query does not count. Cloudflare's own figure: the average Worker uses about 2.2 ms per request. 10 ms free is tight for server-side rendering, generous for a handler that mostly awaits I/O.

**Subrequests count binding calls.** A KV read, an R2 get and a D1 query are each one. A handler looping one D1 query per item hits the free ceiling at 50 items.

**The 500-per-month cap is on Git-integration builds, not deploys.** A Direct Upload — `wrangler pages deploy` from a machine, how this project ships — is not a Pages build, and preview deployments are explicitly unlimited. The current limits page carries no deployments-per-month cap at all.

## `workers.api.error.script_too_large`: three walls, three different exits

That string is the API error returned when the compiled bundle exceeds the plan's size limit. It is the most common way a growing Pages project stops deploying, and it never appears locally, because local development uploads nothing. Three real reports, failing for three different reasons.

**Too many pages compiled into the script.** Jeffh30 on Stack Overflow, 25 September 2023, deploying a SvelteKit blog: *"I now have more than 200 posts with multiple components each. Everything was working well, but recently started getting the following error when deploying to Cloudflare pages"*. No individual post was wrong; the framework compiled every one into `_worker.js`, and around 200 the total crossed the ceiling.

**One binary larger than the whole allowance.** matthewjewell on `nuxt-modules/og-image` issue 193, 13 April 2024: *"Tried deploying a branch with a simple template, it works well locally, but with the 1mb worker limit on Pages (free) I get the `workers.api.error.script_too_large` error as the compiled-wasm file is 2.4mb."* A single 2.4 MB WebAssembly file against a then-1 MB free ceiling. No code-splitting saves that; the asset has to leave the bundle.

**Neither — the upload itself dies.** revmischa on `cloudflare/workers-sdk` issue 1194, 6 June 2022: *"When I upload to pages with wrangler 2 it goes reaaallly slow and then crashes"* — a roughly 300 MB site that uploaded fine under wrangler 1, crawling then failing at 809 of 8009 files. That is the asset pipeline, not the script limit, and it earns a mention because from the terminal it looks identical: a deploy that does not finish.

One correction before copying a fix from any of those threads: **the free ceiling those two hit was 1 MiB, and it is 3 MB today.** Cloudflare raised the Workers Free script limit from 1 MiB to 3 MiB in late November 2024. A project blocked in 2023 may deploy unchanged now.

| Fix | What it does | When it is the right one |
| --- | --- | --- |
| Measure first | `wrangler pages functions build --outdir <dir>`, then gzip the output | Always, before guessing |
| Move the asset out of the bundle | Serve it from R2, KV or as a static Pages asset instead of importing it | A binary — WASM, a font, a model file, a large JSON blob — dominates |
| `import()` instead of a top-level import | Splits the module into a chunk loaded on demand | A heavy dependency is used by a few routes only |
| Delete dependencies | Removes them from the graph entirely | A package was pulled in for one function |
| Upgrade to Workers Paid | 3 MB → 10 MB after compression | The bundle is genuinely that large and every route is needed |
| Move to Workers with static assets | Split across Workers with service bindings | Growth is structural and no single trim will hold |

## Measured: 5.07 MiB of compiled source, 1.29 MiB gzipped

`wrangler pages functions build` runs the same compilation the deploy runs and writes the Worker to a directory instead of uploading it. Take this measurement before believing anything about your headroom.

```bash
cd /path/to/your/pages/project
npx wrangler pages functions build --outdir /tmp/pf-build
#  ✨ Compiled Worker successfully
find /tmp/pf-build -type f
#  /tmp/pf-build/index.js
stat -f "%z" /tmp/pf-build/index.js          # macOS; stat -c %s on Linux
#  5316456
gzip -c /tmp/pf-build/index.js | wc -c
#  1349068
```

Run against this repository on 25 July 2026 with wrangler 4.103.0, all 387 modules compile to **one file**:

| Figure | Bytes | Human |
| --- | --- | --- |
| Compiled, uncompressed | 5,316,456 | 5.07 MiB |
| Compiled, gzipped | 1,349,068 | 1.29 MiB |
| Compression ratio | — | 3.94× |
| Free ceiling, 3 MB gzipped | 3,000,000 | 45.0% consumed |
| Paid ceiling, 10 MB gzipped | 10,000,000 | 13.5% consumed |

Two conclusions. This application sits at 45% of the free ceiling and 13.5% of the paid one — nowhere near the wall. And it would have failed the old 1 MiB free limit by 29%: the same 387 modules could not have been deployed to a free account before November 2024. The 3.94× ratio is why judging by uncompressed size is useless: 5.07 MiB looks alarming against "3 MB" and is under half of it.

## Cold and warm, measured over eighteen requests from one client

Cold-start figures for Workers are usually vendor-quoted. These were taken on 25 July 2026 against a live Pages Function on this site, published with the command so the spread is visible instead of one flattering figure.

```bash
cat > /tmp/fmt.txt <<'EOF'
dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}\n
EOF

# New TLS connection each time, cache-busted so the edge cache cannot answer
for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "@/tmp/fmt.txt" \
    "https://miscsubjects.com/api/articles/cloudflare-os-functions?cb=$RANDOM$i"
done

# Eight requests on one reused connection
curl -s -w "@/tmp/fmt.txt" \
  $(for i in $(seq 8); do echo -n "-o /dev/null https://miscsubjects.com/api/articles/cloudflare-os-functions "; done)
```

| Run | Time to first byte | Total |
| --- | --- | --- |
| New connection, cache-busted, 5 runs | 198 / 280 / 297 / 800 / 810 ms | 248 ms – 1.272 s |
| Same connection reused, 7 runs after the handshake | 103 / 119 / 139 / 145 / 176 / 216 / 286 ms | 104 – 500 ms |
| `/font/Asap-Regular.woff2`, excluded in `_routes.json`, 5 reused | 62 / 62 / 68 / 88 / 122 ms | 64 – 123 ms |

Median time to first byte: **145 ms** for the Function route on a warm connection, **68 ms** for the excluded static route. The Function route ran a D1 query and assembled a JSON document; the font returned `cf-cache-status: HIT`. Not like-for-like, and the gap is not a measurement of routing overhead — but it is the honest size of the difference between a path that enters the Worker and a path `_routes.json` keeps out.

The spread is the finding. The slowest new-connection run took 5.1× the fastest. Any single-number latency claim about Pages Functions from one client is noise.

## A 500 with an empty body, and nothing to look at

Uninen on Hacker News, 24 February 2025: *"a CloudFlare pages function would return 500 + nonsensical error and an empty response in prod. Tried to figure this out all Friday. It was super annoying to fix as there's no way to add more logging"*.

Partly still true.

**Live tailing works.** `wrangler pages deployment tail`, run in the project directory, streams every invocation of the current production deployment as structured JSON — `outcome`, `exceptions` with stack and message, `logs` from your own `console.log`, and the request and response objects. The dashboard shows the same stream. It is live, so it shows a failure only while you are watching and reproducing it.

**Persistent, queryable logs do not.** Cloudflare's own Pages-to-Workers compatibility matrix marks Workers Logs, Logpush, Tail Workers and Source Maps unsupported on Pages and supported on Workers. Real-time logs is the only row supported on both. On Pages you cannot look at what happened an hour ago, and a minified stack trace stays minified because source maps do not upload.

What to do instead, in order:

1. Reproduce with `wrangler pages deployment tail` running. The `exceptions` array carries the message and stack the empty 500 withheld.
2. Wrap middleware in try/catch and return the error. Cloudflare's own middleware example does exactly this — `return new Response(err.message + "\n" + err.stack, { status: 500 })` — turning an empty 500 into a readable one. Gate it behind a header so it is not public.
3. If the Worker throws at module scope, no handler runs and no `console.log` inside one will ever fire. A module-scope throw takes the whole deployment down, which is exactly the "empty response" shape. Check top-level code and imports first.
4. If you need logs you can query after the fact, that is Workers, not Pages.

## Pages or Workers: a verdict for each row

Cloudflare's position, from its own migration guide: *"Unlike Pages, Workers has a distinctly broader set of features available to it, (including Durable Objects, Cron Triggers, and more comprehensive Observability)."* Static asset requests are free on both and Function invocations bill at the same rate, so cost is not the deciding variable. Two blockers stop people who want to follow that advice.

merek on Hacker News, 10 August 2025: *"I had to use Pages since Workers don't support \"Custom domains outside Cloudflare zones\" [1]. There's no way I can transfer the domain since I have subdomains tightly integrated with AWS services."* The compatibility matrix confirms it — custom domains outside Cloudflare zones is the one row marked supported on Pages and unsupported on Workers.

scottydelta, same thread, same day: *"I recently ported an entire TS project from cloudflare workers to a django python app since cloudflare workers don't support choice of region/country when deploying workers."* Placement Hints now bias a Worker toward a named cloud region — `placement.region` set to something like `aws:us-east-1` — but Cloudflare is explicit that Workers run on its network rather than inside cloud regions, so a hint is a latency optimisation. Guaranteed geographic confinement is Regional Services, and the documentation states it is an Enterprise add-on.

| Your situation | Pages | Workers | Verdict |
| --- | --- | --- | --- |
| Nameservers are not Cloudflare's and cannot move | Supported | Not supported | **Stay on Pages.** A hard block, not a preference. |
| You need logs you can query after the incident | Real-time tail only | Workers Logs, Logpush, Tail Workers, source maps | **Move to Workers.** |
| You need a cron schedule in the same project | Not supported | Supported | **Move to Workers,** or keep Pages and put the schedule in a sibling Worker. |
| You need Durable Objects | Only by binding to a separate Worker | Native | **Move to Workers** unless the extra Worker is acceptable. |
| You want file-based routing | Native | Not native | **Stay on Pages,** or adopt a router — Cloudflare's guide names HonoX. |
| Compiled bundle near the ceiling and still growing | One script, one ceiling | Split across Workers with service bindings | **Move to Workers.** |
| You need Queue consumers, Email Workers, Image Resizing or Rate Limiting bindings | Not supported | Supported | **Move to Workers.** |
| You need gradual deployments or the Vite plugin | Not supported | Supported | **Move to Workers.** |
| Static site, a handful of endpoints, Cloudflare DNS | Fine | Fine | **Either.** No reason to migrate. |

What this application would have to change to move: file-based routing across 223 route files becomes an explicit router — the 32 catch-alls map to prefix routes, the 19 single-segment files to path patterns. `_routes.json` has no Workers equivalent and becomes route configuration plus the static-asset binding. `public/` becomes an assets binding. In exchange: Workers Logs, source maps, cron triggers, and the ability to split when the bundle grows. Nothing in that list is hard. Nothing in it is urgent at 45% of the free ceiling.

## `✨ Uploading Functions bundle` is the line that proves the deploy shipped code

```bash
cd /path/to/your/pages/project     # NOT optional — see below
npx wrangler pages deploy public --project-name <your-project> --branch main
```

`public` is the build output directory. Wrangler uploads the files in it, compiles `functions/` from the *current working directory* into one Worker, and uploads `_routes.json` from the output directory. A real successful deploy of this project printed:

```
✨ Compiled Worker successfully
Uploading... (4/4)
✨ Success! Uploaded 0 files (4 already uploaded) (0.61 sec)
✨ Uploading Functions bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete!
```

The absence of the Functions-bundle line is the failure mode. Wrangler resolves `functions/` relative to the shell's working directory, not relative to the output-directory argument. Run the same command from one directory up and it uploads the static files perfectly, finds no `functions/`, ships a Functions-less deployment, and every dynamic route starts returning 404 or 405 — a full production outage from a command that printed no error. Redeploy from the project directory. Nothing else fixes it, because the deployment that shipped genuinely contains no Worker.

## What the ship script refuses to do

`scripts/ship.mjs` wraps that same command in gates. Each gate corresponds to a way a deploy has already gone wrong.

| Gate | What it checks | The failure it prevents |
| --- | --- | --- |
| `verifyProductionLineage` | `git rev-parse HEAD` equals `git rev-parse origin/main`; no uncommitted runtime files; `scripts/check-protected-features.mjs` passes | Shipping local-only code nobody can reproduce or roll back to |
| `verifyProtocolLawClosure` | Every law marked deployed has a unique conformance clause present in `functions/_lib/oip_conformance.js` | A rule declared live with no code enforcing it |
| `reportStrandedWork` | Lists saved branches with commits not in `main`; informational, never blocks | Silent loss of work that never rejoined the live line |
| Deploy lease | A KV key with an 1800-second TTL and a nonce, re-read after writing to confirm ownership, with an acquire receipt in the events database | Two machines deploying at once |
| Preview-first promotion | Deploys to a preview alias, smoke-tests `/design` there with up to 12 retries at 10-second intervals, and only then deploys to `main` | A render-time throw reaching production |
| Production smoke | Re-tests five real paths against the live host with up to 5 retries, failing if a body is under 1500 bytes or matches `/render error\|internal server error\|cannot read\|referenceerror\|is not defined\|1101 \|worker threw/i` | A deploy that succeeded and a site that is broken |

The preview-first step carries a subtlety that generalises to any Pages project with a database. Preview deployments bind to a separate, empty preview database, so any page whose first act is a populated query returns 500 on preview while being perfectly healthy in production. The script splits the smoke sets accordingly: the preview set holds only pages that render from code, the production set holds the data pages. A module-scope error takes the entire Worker down, so it still surfaces on preview even though the data pages cannot be checked there.

## Symptom, cause, fix

| Symptom | Real error string | Cause | Fix |
| --- | --- | --- | --- |
| Deploy rejected, no upload | `workers.api.error.script_too_large` | Compiled bundle over 3 MB gzipped (free) or 10 MB (paid) | Measure with `wrangler pages functions build --outdir` then gzip; move binaries to R2 or KV; dynamic-import heavy modules; upgrade the plan |
| Deploy succeeded, every `/api/*` returns 404 or 405 | none — the deploy printed no error | `wrangler pages deploy` ran outside the project directory, so `functions/` was never compiled | Confirm `✨ Uploading Functions bundle` in the output; redeploy from the project directory |
| 500 with an empty body, production only | none in the response | An uncaught throw, often at module scope, so no handler ran | Reproduce with `wrangler pages deployment tail` and read the `exceptions` array; check top-level code and imports first |
| Request reaches the wrong file | none | A `[[path]].js` catch-all in an ancestor directory matched before the specific file | Handle the path in the catch-all too, or move the specific file out of its subtree |
| `/a/thing/extra` returns a static asset or 404 | none | `[slug].js` matches exactly one segment | Rename to `[[slug]].js` and read `context.params.slug` as an array |
| POST returns 405 while GET works | none | The file exports `onRequestGet` only | Add `onRequestPost`, or export `onRequest` and branch on `request.method` |
| Handler dies partway under load, no message | Error 1102, `Worker exceeded resource limits` | CPU time over 10 ms (free) or the configured paid ceiling | CPU excludes I/O waiting — profile actual computation; raise `limits.cpu_ms` on paid |
| Handler fails after roughly 50 binding calls | none in the response | Subrequest ceiling: 50 per invocation on free | Batch the queries, or move the loop to a Queue consumer |
| A font or image request bills as an invocation | none | `_routes.json` missing, or the path is not in `exclude` | Add the prefix to `exclude`; exclude always beats include |
| `_routes.json` rejected at deploy | none in the response | Over 100 include and exclude rules combined, a rule over 100 characters, or zero include rules | Collapse rules into wildcards; at least one include rule is mandatory |

## Related

- [One Cloudflare account, one build](/a/cloudflare-os) — the map of every component and which binding reaches it.
- [Workers and Durable Objects](/a/cloudflare-os-workers) — the six things that are separate Workers, and the three tests for when a job stops belonging in this deployment.

## The fresh compiler receipt moved the file count, not the architecture

Wrangler 4.103.0 compiled the current working tree at `2026-07-26T05:55:10.960Z`. The measurement wrote its output only to a temporary directory.

| Fresh check | Result |
| --- | ---: |
| JavaScript modules under `functions/` | 391 |
| Route files outside `functions/_lib/` | 224 |
| Shared `_lib` modules | 167 |
| Files exporting at least one request handler | 214 |
| Request-handler exports | 271 |
| `onRequestGet` exports | 170 |
| Single-segment `[param].js` routes | 19 |
| Optional catch-all `[[path]].js` routes | 32 |
| Root middleware files | 1; 947 lines |
| `_routes.json` rules | 1 include + 15 exclude |
| Compiled source | 5,325,637 bytes |
| Gzipped bundle | 1,350,472 bytes; 45.0% of the 3 MB free ceiling |

The three extra modules since the 25 July inventory produced one extra route file, two extra shared modules, one extra handler-bearing file and one extra GET export. The deployment shape did not change: every route and shared module still landed in one Worker bundle.

Reproduce the bundle measurement:

```bash
OUTDIR="$(mktemp -d)"
npx wrangler pages functions build --outdir "$OUTDIR"
stat -f "%z" "$OUTDIR/index.js"          # macOS
gzip -c "$OUTDIR/index.js" | wc -c
```

Expected proof line: `✨ Compiled Worker successfully`. Judge the plan limit against the gzip result, not the first number.

## Sources

1. Pages Functions routing — https://developers.cloudflare.com/pages/functions/routing/
2. Pages Functions API reference — https://developers.cloudflare.com/pages/functions/api-reference/
3. Pages Functions middleware — https://developers.cloudflare.com/pages/functions/middleware/
4. Pages _routes.json routing controls — https://developers.cloudflare.com/pages/functions/routing/
5. Cloudflare Pages limits — https://developers.cloudflare.com/pages/platform/limits/
6. Cloudflare Workers limits — https://developers.cloudflare.com/workers/platform/limits/
7. Pages Functions debugging and logging — https://developers.cloudflare.com/pages/functions/debugging-and-logging/
8. Migrate from Pages to Workers — https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
9. Pages Direct Upload — https://developers.cloudflare.com/pages/get-started/direct-upload/
10. Pages Functions local development — https://developers.cloudflare.com/pages/functions/local-development/
11. OpenNext Cloudflare bundle troubleshooting — https://opennext.js.org/cloudflare/troubleshooting
12. Cloudflare workers-sdk — https://github.com/cloudflare/workers-sdk
13. Independent WASM bundle-limit reproduction — https://github.com/nuxt-modules/og-image/issues/193
14. How to fix workers.api.error.script_too_large when deploying Sveltekit to Cloudflare pages — https://stackoverflow.com/questions/77173778/how-to-fix-workers-api-error-script-too-large-when-deploying-sveltekit-to-cloudf
15. Deploying on Cloudflare Pages, script_too_large? — https://github.com/nuxt-modules/og-image/issues/193
16. 🐛 BUG: pages publish very slow / crashing — https://github.com/cloudflare/workers-sdk/issues/1194
17. Cloudflare recommends migrating from Pages to Workers — https://news.ycombinator.com/item?id=44854848
18. Magit manuals are available online again — https://news.ycombinator.com/item?id=45936226
19. Claude 3.7 Sonnet and Claude Code — https://news.ycombinator.com/item?id=43164386
20. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43646198
21. Cloudflare recommends migrating from Pages to Workers — https://news.ycombinator.com/item?id=44855519
22. 5 months ago I'd never coded anything. I now have full-stack analytics platform — https://news.ycombinator.com/item?id=47146087
23. Fresh first-party Functions tree inventory — https://miscsubjects.com/api/articles/cloudflare-os-functions
24. Fresh first-party Pages Functions compilation — https://miscsubjects.com/api/articles/cloudflare-os-functions
25. First-party cold and warm request timing — https://miscsubjects.com/api/articles/cloudflare-os-functions
26. First-party overlapping-route receipt — https://miscsubjects.com/api/articles/cloudflare-os-functions
27. First-party deploy-output receipt — https://miscsubjects.com/api/articles/cloudflare-os-functions
28. Fresh first-party _routes.json inventory — https://miscsubjects.com/api/articles/cloudflare-os-functions


---

# R2 cuts a 10 TB delivery bill from $923 to $18.45

slug: cloudflare-os-r2 · https://miscsubjects.com/a/cloudflare-os-r2 · tags: cloudflare, architecture, r2, cloudflare-os · updated 2026-07-26T03:59:27.570Z

Cloudflare R2 is object storage: give it a key like `img/up/hero.png` and some bytes, and it hands them back on request. It speaks two dialects: the Amazon S3 HTTP API, so existing S3 tools work against it, and a native binding inside a Cloudflare Worker where the bucket is a JavaScript object with `put`, `get`, `list` and `delete`. Objects go to 5 TiB, keys to 1,024 bytes, and a bucket holds any number of them.

The reason anyone brings it up is the price of getting bytes *out*. Amazon charges for that. Cloudflare does not. The rest of this page is the arithmetic that follows, plus the things R2 will not do for you.

[[embed:source:s2]]

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Zero egress is real; the meter is on the operations

R2 bills three things: how much you store, and two classes of API call. **Class A** operations change state: `PutObject`, `CopyObject`, `ListObjects`, `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`. **Class B** operations read state: `GetObject`, `HeadObject`, `HeadBucket`. Deletes are free. Bandwidth to the internet is free.

Rates, read from the pricing page on 2026-07-26:

| | Standard | Infrequent Access |
| --- | --- | --- |
| Storage | $0.015 / GB-month | $0.01 / GB-month |
| Class A (writes, lists) | $4.50 / million | $9.00 / million |
| Class B (reads) | $0.36 / million | $0.90 / million |
| Data retrieval | none | $0.01 / GB |
| Egress to the internet | free | free |
| Free each month | 10 GB-month, 1M Class A, 10M Class B | none; the free tier is Standard only |
| Minimum storage duration | none | 30 days |

Cloudflare rounds usage up to the next unit: 1,000,001 operations bills as two million, 1.1 GB-month bills as 2 GB-month.

[[embed:source:s1]]

A reader of that same page put the obvious objection plainly.

[[embed:source:s21]]

He is describing the meter correctly and drawing the wrong conclusion. Price the overage. Class B beyond the free 10 million costs $0.36 per additional million. On the ten-terabyte workload computed below, the S3 egress line is $891.00. For R2's metered operations to cost that much, you would have to make **2.475 billion** Class B calls beyond the free allowance in one month: 891 ÷ 0.36 × 1,000,000. The class-ops meter is real. It is roughly three orders of magnitude away from being the thing that costs you money.

## Serving 10 TB a month: $18.45 on R2, $923.00 on S3

The workload: 1,000 GB stored, average object 500 KB (so 2,000,000 objects), 200,000 new objects written during the month, and 10,000 GB served to the public internet, or 20,000,000 GET requests. S3 prices are `us-east-1`, pulled from the AWS Price List API on 2026-07-26: Standard storage $0.023/GB-month, Tier-1 requests (PUT, COPY, POST, LIST) $0.005 per 1,000, Tier-2 requests (GET and all others) $0.0004 per 1,000, data transfer out to the internet $0.09/GB for the first 10 TB beyond the 100 GB monthly free allowance.

| Line | R2 arithmetic | R2 | S3 arithmetic | S3 |
| --- | --- | --- | --- | --- |
| Storage | (1,000 − 10 free) × $0.015 | $14.85 | 1,000 × $0.023 | $23.00 |
| Writes | 200,000 of 1,000,000 free | $0.00 | 200,000 × $0.005/1,000 | $1.00 |
| Reads | (20,000,000 − 10,000,000) × $0.36/M | $3.60 | 20,000,000 × $0.0004/1,000 | $8.00 |
| Egress | 10,000 GB, unmetered | $0.00 | (10,000 − 100) × $0.09 | $891.00 |
| **Total** | | **$18.45** | | **$923.00** |

Fifty times cheaper, and the ratio is almost entirely one line: egress is 96.5% of the S3 bill. The table also shows that R2's operation rates are not a gimmick to claw the egress back. Class A at $4.50/million undercuts S3's Tier-1 at $5.00/million, and Class B at $0.36/million undercuts Tier-2 at $0.40/million. R2 is 10% cheaper per call *and* free on bandwidth.

[[embed:source:s15]]

Cloudflare's CTO stated the free-tier gap when R2 launched, and the allowances he named still hold on the page fetched today.

[[embed:source:s19]]

Two published totals for real public-serving workloads, both itemised. A 33 GB WordPress plugin mirror on R2 plus Workers, and a full archive of every SEC filing served at roughly twice the SEC's own volume:

[[embed:source:s24]]

[[embed:source:s26]]

Neither workload needed versioning, and neither was cold. That is why they land where they do.

## The workload where S3 wins is the one you never read

R2 has exactly two storage classes. S3 has a ladder that goes much colder. For a write-once archive that is almost never read and never leaves the cloud, the egress advantage is worth nothing and the storage floor decides.

The workload: 100,000 GB (100 TB) of compliance records, written once, read a handful of times a year, consumed inside the same cloud.

| Where it sits | Rate | Monthly |
| --- | --- | --- |
| R2 Standard | (100,000 − 10) × $0.015 | $1,499.85 |
| R2 Infrequent Access | 100,000 × $0.01 (no free tier applies) | $1,000.00 |
| S3 Glacier Flexible Retrieval | 100,000 × $0.0036 | $360.00 |
| S3 Deep Archive Access tier | 100,000 × $0.00099 | $99.00 |

R2's cheapest class costs 2.8× the Glacier Flexible rate and 10.1× the Deep Archive rate. There is no colder tier to move to; Standard and Infrequent Access are the whole ladder. If your bytes are cold and captive, stay on S3.

[[embed:source:s5]]

The person who ran both and stayed on AWS drew the boundary in one sentence.

[[embed:source:s22]]

## Infrequent Access pays only below about one read every two months

Infrequent Access looks like a third off the storage price. The retrieval fee eats it almost immediately. Take a 1 GB object held for one month and read `r` times:

- Standard: `$0.015 + r × $0.00000036`
- Infrequent Access: `$0.010 + r × $0.0000009 + r × 1 GB × $0.01`

Set them equal: `0.005 = 0.01000054r`, so `r ≈ 0.5`. The retrieval fee is 99.99% of the right-hand side; the operation-rate difference is noise. **A 1 GB object must be read less than once every two months for Infrequent Access to be cheaper.** Add the 30-day minimum billing duration, where you pay a full month even if you delete on day two, and the class is for backups and cold originals, nothing else.

Move objects there with a lifecycle rule rather than by hand. The transition itself is billed as a Class A operation.

```bash
npx wrangler r2 bucket lifecycle add miscsubjects-ledger \
  --name "archive-old-events" \
  --prefix "events/" \
  --storage-class InfrequentAccess \
  --transition-days 30
```

Lifecycle rules also delete: an expiration rule on a prefix is how an ingest bucket stops growing forever, and how incomplete multipart uploads get cleaned up. They expire after 7 days by default. A bucket accepts up to 1,000 rules.

[[embed:source:s6]]

## R2 will not keep the old version of an object

This is the gap that costs people data. When you `put` to a key that already exists, the previous bytes are gone.

[[embed:source:s23]]

The S3 compatibility table marks `PutBucketVersioning`, `GetBucketVersioning`, `PutObjectLockConfiguration` and `GetObjectLockConfiguration` all unsupported. Versioning and Object Lock are the two standard defences against ransomware and against a human running the wrong script; R2 offers neither in S3's form.

[[embed:source:s4]]

It does have **bucket locks**, a per-prefix retention rule blocking deletion and overwriting for a fixed period or indefinitely, enforced with `10069 / ObjectLockedByBucketPolicy` and HTTP 403. That stops deletion. It does not give you "fetch me yesterday's copy".

[[embed:source:s7]]

For that you write versioning yourself: never overwrite a key, always write a new one, keep a pointer.

```js
// Content-addressed writes: the key carries the version, so nothing is ever overwritten.
export async function putVersioned(env, logicalKey, body, contentType) {
  const bytes = body instanceof ArrayBuffer ? body : new TextEncoder().encode(body);
  const digest = await crypto.subtle.digest('SHA-256', bytes);
  const hash = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');
  const versionKey = `${logicalKey}/${Date.now()}-${hash.slice(0, 12)}`;
  await env.R2.put(versionKey, bytes, { httpMetadata: { contentType } });
  // The pointer is one small object; reading it is one Class B call.
  await env.R2.put(`${logicalKey}/current`, versionKey, {
    httpMetadata: { contentType: 'text/plain' },
  });
  return { versionKey, hash };
}

export async function getCurrent(env, logicalKey) {
  const ptr = await env.R2.get(`${logicalKey}/current`);
  if (!ptr) return null;
  return env.R2.get(await ptr.text());   // second Class B call
}
```

The cost of doing it this way: every read is two Class B operations instead of one, and old versions accumulate until a lifecycle rule expires them. At $0.36 per million reads, the doubled read is $0.36 per million objects fetched. That is the actual price of the missing feature.

## The free-egress trust question, answered from the terms

The sharpest objection is not about the price list but about whether it is load-bearing.

[[embed:source:s25]]

The worry points at a real clause, and the clause says the opposite of what it assumes. Cloudflare's Service-Specific Terms restrict the **CDN** on Free, Pro and Business plans: *"Unless you are an Enterprise customer, Cloudflare offers specific Paid Services (e.g., the Developer Platform, Images, and Stream) that you must use in order to serve video and other large files via the CDN."* The Developer Platform named there is the thing R2 belongs to. Using R2 to serve large files is the compliant path, not the risky one.

The Developer Platform section carries its own limit, and it is a different kind: *"Cloudflare may temporarily limit your storage and/or the number of requests you can make or receive using the Developer Platform if processing such requests would put an undue burden on the Cloudflare network."* Rate limiting, stated in advance. Not a retroactive per-GB bill.

Verdict: the enforcement risk on R2 is throttling under abnormal load, not an undisclosed egress charge. The pricing page's footnote is unambiguous: *"Egressing directly from R2, including via the Workers API, S3 API, and r2.dev domains does not incur data transfer (egress) charges and is free."* What it does not cover is metered services you bolt on top; those bill separately.

[[embed:source:s14]]

## Every call, and which meter it hits

Bind the bucket first. In `wrangler.toml`:

```toml
[[r2_buckets]]
binding = "R2"
bucket_name = "miscsubjects-ledger"
```

`binding` is the variable name your code sees; `bucket_name` is the real bucket. Create the bucket before the first deploy, or the binding fails:

```bash
npx wrangler r2 bucket create miscsubjects-ledger
npx wrangler r2 bucket list
```

| Call | What it does | Meter |
| --- | --- | --- |
| `env.R2.put(key, value, opts)` | Stores bytes; returns an `R2Object`. Strongly consistent: once the promise resolves, every reader worldwide sees it | Class A |
| `env.R2.get(key, opts)` | Returns `R2ObjectBody` with `.body` as a stream, or `null` if absent | Class B |
| `env.R2.head(key)` | Metadata only, no body, or `null` | Class B |
| `env.R2.list(opts)` | Up to 1,000 keys, lexicographic, `opts.prefix` to scope | Class A |
| `env.R2.delete(key or key[])` | Up to 1,000 keys per call | free |
| `env.R2.createMultipartUpload(key)` | Starts a multipart upload | Class A |
| `upload.uploadPart(n, body)` | One part; all non-final parts must be the same size and ≥ 5 MiB | Class A each |
| `upload.complete(parts)` | Finishes it | Class A |

[[embed:source:s3]]

Note the trap in that table: **`list` is a Class A operation**, priced 12.5× a read. A paginated file browser that lists on every page view burns the expensive quota, not the cheap one.

A single `put` accepts up to 5 GiB. Above that, multipart, or you get `100100 / EntityTooLarge`:

```js
const upload = await env.R2.createMultipartUpload('big/archive.tar');
const parts = [];
let n = 1;
for (const chunk of chunksOf(stream, 16 * 1024 * 1024)) {   // 16 MiB, uniform
  parts.push(await upload.uploadPart(n++, chunk));
}
await upload.complete(parts);
```

From outside a Worker, use the S3 API against `https://<ACCOUNT_ID>.r2.cloudflarestorage.com` with `region: "auto"`, or hand a browser a presigned URL so it uploads straight to R2 without the bytes passing through your server:

```js
import { AwsClient } from 'aws4fetch';

const r2 = new AwsClient({ accessKeyId: ACCESS_KEY_ID, secretAccessKey: SECRET_ACCESS_KEY });
const url = new URL(`https://${ACCOUNT_ID}.r2.cloudflarestorage.com/my-bucket/uploads/${name}`);
url.searchParams.set('X-Amz-Expires', '3600');           // seconds
const signed = await r2.sign(new Request(url, { method: 'PUT' }), {
  aws: { signQuery: true, service: 's3' },
});
// signed.url is safe to hand to a browser; it expires in one hour.
```

Tamper with any signature parameter and the request fails with `10035 / SignatureDoesNotMatch`; let it age out and you get `10018 / ExpiredRequest`.

[[embed:source:s9]]

## Serving an object publicly, and the header that decides your bill

Three ways to make a bucket readable from the internet. A **custom domain** puts it behind a hostname on your zone, the only option that gets Cloudflare Cache, WAF rules and Bot Management. An **r2.dev subdomain** is one toggle, documented as non-production. A **Worker route** gives you the object plus whatever logic you put in front of it. The third, in nine lines:

[[embed:source:s8]]

```js
// functions/img/[[path]].js: every /img/* URL is an R2 key.
export async function onRequestGet(context) {
  const { params, env } = context;
  const key = 'img/' + (Array.isArray(params.path) ? params.path.join('/') : String(params.path || ''));
  if (!env.R2) return new Response('no R2', { status: 500 });
  const obj = await env.R2.get(key);
  if (!obj) return new Response('not found', { status: 404 });
  const ct = obj.httpMetadata?.contentType || 'image/png';
  return new Response(obj.body, { headers: { 'content-type': ct, 'cache-control': 'public, max-age=31536000' } });
}
```

The URL path *is* the object key: no media table, no second name for a file, and a rename is a copy.

`cache-control: public, max-age=31536000` is the line that matters financially. Cached responses never reach R2, so they cost no Class B operation. Fetched live, the header comes back on the wire:

```text
$ curl -sI https://miscsubjects.com/img/up/cloudflare-os-r2-hero-card.png
HTTP/2 200
content-type: image/png
content-length: 51205
cache-control: public, max-age=31536000
cf-cache-status: MISS
cf-ray: a210b8bcbb715616-SJC
```

A one-year max-age is only safe because a changed image is written under a new key, never patched in place. Serve mutable objects this way and you will serve stale bytes for a year.

[[embed:source:s17]]

Cache-hit ratio is not a rounding error on the bill:

[[embed:source:s20]]

## When a row outgrows D1, the bytes move to R2 and the row keeps a pointer

D1, the SQLite database in the same account ([D1 as the spine](/a/cloudflare-os-d1)), has a hard per-value ceiling: **2,000,000 bytes** for any string, BLOB or row. Exceed it and every write to that row fails with `D1_ERROR: string or blob too big`, the driver's rendering of SQLite's `SQLITE_TOOBIG`.

This application hit that wall storing article revision history. Each write snapshots the previous version — full body, claims and sources — into a JSON blob on the row. One article's metadata reached **2,068,258 bytes** across 24 snapshots, 68,258 over the cap, and from then on every write to it returned HTTP 500.

The fix generalises into a rule: **when a field outgrows its row, the field moves to object storage and the row keeps a pointer plus a hash.** The pointer is small and fixed-size; the hash is what makes the pointer trustworthy.

The key format is one line in `functions/_lib/revisions_r2.js`:

```js
const PREFIX = "revisions/";
function r2Key(slug, n) { return `${PREFIX}${slug}/${n}.json`; }
```

So revision 3 of this article lives at `revisions/cloudflare-os-r2/3.json`. What stays in D1 is a nine-field index entry, written by the same function:

```js
return {
  n: full.n, ts: full.ts, title: full.title, status: full.status,
  register: full.register, bytes: full.body.length,
  prev_hash: full.prev_hash, hash: full.hash,
  r2_key: key,
};
```

`prev_hash` and `hash` are the point: the chain is verifiable from D1 alone, no R2 read needed to prove the history has not been edited, while the bytes are fetched only when someone asks for a specific revision. That 2,068,258-byte row became **206,362 bytes**, and the original revision is still retrievable.

[[embed:source:s16]]

Measured across the bucket today: 4,930 objects under `revisions/`, 148,075,146 bytes, 1,053 distinct articles, mean 30,036 bytes per revision. The largest single history, `revisions/bpc-157/`, is 119 objects and 14,503,688 bytes — 7.25× the D1 row cap, so that article alone would be permanently unwritable under the old scheme.

Proof the offload preserved the history rather than truncating it:

```bash
$ curl -s "https://miscsubjects.com/api/articles/cloudflare-os-r2?rev=0" | jq '{rev,is_head,prev_hash,body_len:(.body|length)}'
{ "rev": 0, "is_head": false, "prev_hash": "genesis", "body_len": 2878 }
```

That body came out of R2. Nothing but the index is in the database.

## Choosing between R2, S3, KV, D1 and Durable Object storage

| Workload | Put it in | Why, and the limit that decides it |
| --- | --- | --- |
| Images, video, uploads, anything served to the public internet | **R2** | Free egress; 5 TiB per object; strongly consistent |
| Cold archive, rarely read, consumed inside AWS | **S3 Glacier** | $0.0036–$0.00099/GB-month against R2's $0.01 floor |
| A file with a legal retention requirement and a need to read yesterday's copy | **S3** | R2 has bucket locks but no object versioning |
| Small values read constantly from many places — flags, prompts, rendered snapshots | **Workers KV** | Values to 25 MiB, but eventually consistent and 1 write/second per key |
| Anything you need to query, join, filter or index | **D1** | 10 GB per database, 2 MB per value; a bucket cannot answer `WHERE` |
| Per-entity state needing serialized writes and coordination | **Durable Object storage** | 10 GB per object, single-threaded execution ([the Workers layer](/a/cloudflare-os-workers)) |
| A row that has outgrown 2 MB | **R2, with a pointer in D1** | The offload pattern above |
| Blobs written and read once, under 25 MiB, with no need for a URL | **either KV or R2** | R2 unless you need sub-millisecond reads at the edge |

[[embed:source:s10]]

[[embed:source:s11]]

[[embed:source:s12]]

[[embed:source:s13]]

Two failure modes worth naming: KV used as a database (eventually consistent, so a read after a write may return the old value), and R2 used as a database (no query, only a lexicographic key scan at Class A prices).

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `no R2 binding` / `no R2`, HTTP 500 | The Worker deployed without the `r2_buckets` entry, or the bucket does not exist yet | `npx wrangler r2 bucket create <name>`, then confirm the `[[r2_buckets]]` block is present in every environment, including `[[env.preview.r2_buckets]]` |
| `10006 / NoSuchBucket`, HTTP 404 | Bucket name typo, or the bucket is in a different account | `npx wrangler r2 bucket list` and compare byte for byte |
| `get()` returns `null` instead of throwing | Not an error — the Workers API returns `null` for a missing key rather than raising `NoSuchKey` | Branch on `null`; do not wrap in `try/catch` and expect a throw |
| `100100 / EntityTooLarge`, HTTP 400 | Single-part upload over 5 GiB | Switch to `createMultipartUpload`; parts uniform and ≥ 5 MiB |
| `10011 / EntityTooSmall` or `10048 / InvalidPart`, HTTP 400 | A non-final part under 5 MiB, or parts of differing sizes | Every part except the last must be ≥ 5 MiB and identical in size |
| `10058 / TooManyRequests`, HTTP 429 | More than one write per second to the same key | Shard the key, or funnel writes for that key through a Durable Object |
| `10035 / SignatureDoesNotMatch`, HTTP 403 | A presigned URL was edited, or the secret is wrong | Regenerate; check URL encoding of the key |
| `10069 / ObjectLockedByBucketPolicy`, HTTP 403 | A bucket lock retention rule covers that prefix | Wait out the retention period; a lock is not overridable |
| `D1_ERROR: string or blob too big` on a write that used to work | A JSON column crossed D1's 2,000,000-byte per-value cap | Offload the heavy field to R2, keep a pointer and a hash in the row |
| `wrangler r2 object put --file` fails with `fetch failed` | Reported against Wrangler 3.74.0 and still open | Pipe the file instead: `cat f.txt \| npx wrangler r2 object put bucket/f.txt --pipe` |
| Multipart `complete()` returns empty `customMetadata` in production but not in `wrangler dev` | Open bug, reported against Wrangler 3.57.2 | Re-read with `head()` after completing; the metadata is stored, only the return value drops it |

## How the numbers on this page were measured

Four measurements, taken 2026-07-26 against the live production bucket from a laptop served by the San Jose edge (`cf-ray` suffix `SJC`).

[[embed:source:s27]]

**1 — Buckets and inventory.** `npx wrangler r2 bucket list` returns three buckets: `loop-data-raw` (2026-05-31), `miscsubjects-ledger` (2026-06-09), `miscsubjects-store` (2026-06-16). The inventory of `miscsubjects-ledger` was walked through the authenticated list route, 1,000 keys per page, following the cursor:

```bash
curl -s -H "x-terminal-key: $TERMINAL_KEY" \
  "https://miscsubjects.com/api/r2/?list=1&limit=1000&cursor=$CURSOR"
```

62 pages, **41,809 objects, 3,904,595,147 bytes (3.64 GiB)** — under the 10 GB free allowance, so today's storage line is $0.00. By prefix: `events/` 34,258 objects / 2,241 MB; `revisions/` 4,930 / 148 MB; `img/gen/` 387 / 765 MB; `img/up/` 191 / 432 MB; `docs/` 1,332 / 9.5 MB. The walk itself cost 62 Class A operations.

**2 — Put and get latency.** A 65,536-byte JSON object written and read six times each over one reused TLS connection, at the deliberately-named temporary key `tmp/measure-2026-07-25-delete-me.json`, then deleted — `DELETE` returned `{"ok":true,...,"deleted":true}` and a follow-up list of `tmp/` returned zero objects.

```bash
curl -s -X PUT "https://miscsubjects.com/api/r2/tmp/measure-2026-07-25-delete-me.json" \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  --data-binary @probe.json -w "%{time_total}\n"
```

PUT round trips: 535, 587, 634, 737, 801, 2,471 ms — median **686 ms**. GET: 96, 104, 125, 148, 185, 192 ms — median **136 ms**. These are full client-to-edge-to-R2-to-client times through the Worker, not R2's internal service time; the first request in each series carries the TLS handshake, 86 ms and 71 ms.

**3 — Public object headers.** `curl -sI https://miscsubjects.com/img/up/cloudflare-os-r2-hero-card.png` returned HTTP 200, `content-length: 51205`, `content-type: image/png`, `cache-control: public, max-age=31536000`, `cf-cache-status: MISS`. Full body fetch, 189 ms.

[[embed:source:s18]]

**4 — A revision read out of R2.** `curl -s "https://miscsubjects.com/api/articles/cloudflare-os-r2?rev=0"` returned `rev: 0`, `is_head: false`, `prev_hash: "genesis"`, `hash: 24915ae889f6184140a56e89d476d28a31e0cf0e28d31c0990c2aab6f7a626fc`, body length 2,878 — out of `revisions/cloudflare-os-r2/0.json`, not the database. Account identifiers are omitted throughout; the S3 endpoint appears as `<ACCOUNT_ID>.r2.cloudflarestorage.com`.

The index for the rest of this stack is [the Cloudflare account as an operating system](/a/cloudflare-os).


## Sources

1. Cloudflare R2 pricing — https://developers.cloudflare.com/r2/pricing/
2. How R2 works — https://developers.cloudflare.com/r2/how-r2-works/
3. Workers R2 API reference — https://developers.cloudflare.com/r2/api/workers/workers-api-reference/
4. S3 API compatibility — https://developers.cloudflare.com/r2/api/s3/api/
5. R2 storage classes — https://developers.cloudflare.com/r2/buckets/storage-classes/
6. R2 object lifecycles — https://developers.cloudflare.com/r2/buckets/object-lifecycles/
7. R2 bucket locks — https://developers.cloudflare.com/r2/buckets/bucket-locks/
8. R2 public buckets — https://developers.cloudflare.com/r2/buckets/public-buckets/
9. R2 presigned URLs — https://developers.cloudflare.com/r2/api/s3/presigned-urls/
10. R2 limits — https://developers.cloudflare.com/r2/platform/limits/
11. Workers KV limits — https://developers.cloudflare.com/kv/platform/limits/
12. D1 limits — https://developers.cloudflare.com/d1/platform/limits/
13. Durable Objects limits — https://developers.cloudflare.com/durable-objects/platform/limits/
14. Cloudflare Developer Platform terms — https://www.cloudflare.com/service-specific-terms-developer-platform/
15. AWS S3 us-east-1 Price List API — https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonS3/current/us-east-1/index.json
16. Clarify presigned URL docs — https://github.com/cloudflare/cloudflare-docs/issues/19190
17. Unable to put an object with a key containing three dots to R2 — https://github.com/cloudflare/workers-sdk/issues/3520
18. Cloud object storage benchmark — https://aimultiple.com/cloud-object-storage
19. The next chapter for Cloudflare Workers: open-source — https://news.ycombinator.com/item?id=31314273
20. How Canva saves Amazon S3 costs — https://news.ycombinator.com/item?id=36392670
21. WordPress Plugin Mirror Downloader (Proof of Concept) — https://news.ycombinator.com/item?id=41753985
22. Ask HN: S3(AWS) vs R2(CF)–Which is better? — https://news.ycombinator.com/item?id=47700966
23. Comparing AWS S3 with Cloudflare R2: Price, Performance and User Experience — https://news.ycombinator.com/item?id=42257068
24. Hetzner continues its growth in the US with a new location — https://news.ycombinator.com/item?id=33865229
25. Comparing AWS S3 with Cloudflare R2: Price, Performance and User Experience — https://news.ycombinator.com/item?id=42257094
26. Cloudflare R2 let me serve almost twice as much data this month as the SEC for $10.80 — https://old.reddit.com/r/CloudFlare/comments/1qhbrey/cloudflare_r2_let_me_serve_almost_twice_as_much/
27. Live R2 object headers — https://miscsubjects.com/img/up/cloudflare-os-r2-hero-card.png


---

# Workers KV makes reads fast by making writes slow and consistency optional

slug: cloudflare-os-kv · https://miscsubjects.com/a/cloudflare-os-kv · tags: cloudflare, architecture, kv, cloudflare-os, workers-kv, cache, eventual-consistency, durable-objects, pricing · updated 2026-07-26T03:59:24.251Z

Workers KV is a key-value store with one central copy of your data and a cache of that copy in every Cloudflare location that has recently asked for it. Reads from a location that already holds the key are the fastest storage read on the platform. Writes go to the centre and take their time getting everywhere else. Every decision on this page follows from that one asymmetry.

The short answer to "should this state live in KV": if losing sixty seconds of freshness in another continent is survivable, yes. If two requests might write the same key at the same time and the result has to be correct, no.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Cloudflare's own Workers lead calls the database use a misuse

Kenton Varda, who leads the Workers team, answered a developer who had adopted KV as their datastore:

> KV is not a distributed database and is really not intended as a database alternative at all. It's more meant for distributing bits of config globally. Cost aside, writes are way too slow for database-ish use

He pointed at Durable Object SQLite storage and at Hyperdrive instead. Take the sentence literally: **bits of config**. Flags, routing tables, rendered snapshots, allow-lists, prompt blocks. Not carts, not counters, not sessions that mutate, not anything two writers touch.

[[widget:0]]

## Eventual consistency, in the exact words of the reference

The Workers Binding API reference states the write behaviour without softening it:

> Due to the eventually consistent nature of KV, concurrent writes to the same key can end up overwriting one another.

and

> Writes are immediately visible to other requests in the same global network location, but can take up to 60 seconds (or the value of the `cacheTtl` parameter of the `get()` or `getWithMetadata()` methods) to be visible in other parts of the world.

The read reference is equally blunt: `get()` and `getWithMetadata()` "may return stale values". The concepts page adds the trap most people miss — **a miss is cached too**:

> Negative lookups indicating that the key does not exist are also cached, so the same delay exists noticing a value is created as when a value is changed.

So a location that asked for `flag:new_checkout` before you created it will keep answering `null` for up to sixty seconds after the key exists. Nothing retries on your behalf.

### What a reader in another region actually sees after a write

| Moment after the write | Same location as the writer | A location that has never read the key | A location that read the key (or its absence) recently |
| --- | --- | --- | --- |
| 0–1 s | New value, usually | New value — nothing cached to serve instead | Old value, or `null` |
| 1–60 s | New value | New value | Old value, or `null`, until the cached copy times out |
| After 60 s | New value | New value | New value |
| With `cacheTtl: 3600` set on the read | New value | New value | Old value for up to an hour |

"Usually" is the documentation's word, not a hedge added here: *"At the Cloudflare global network location at which changes are made, these changes are usually immediately visible. However, this is not guaranteed and therefore it is not advised to rely on this behaviour."* There is no read-after-write guarantee anywhere in KV, including at the writing location.

### The safety rule, applied to real states

| State | Safe in KV | Why |
| --- | --- | --- |
| Rendered page snapshot | Yes | A stale page is a slightly old page. The next render replaces it. |
| Feature flag, kill switch | Yes | Rollout is a minute, not a millisecond. One writer, an operator. |
| Routing table, agent prompt block | Yes | Changes are deliberate and infrequent; a minute of skew is invisible. |
| Allow-list / deny-list | Yes, with a caveat | Adding is fine. Revocation is not — a revoked entry stays live for the propagation window. Pair with a short `cacheTtl` or a second, authoritative check. |
| Session state that mutates per request | No | Read-modify-write on the same key. Concurrent writes overwrite each other. |
| Counter, quota, rate limit | No | Same lost-update problem, every increment. |
| Shopping cart, order status | No | Two tabs, two writes, one survivor, no error. |
| A lock over anything contended | No | See the lock section below. |
| The only copy of any fact | No, except flags | Nothing to rebuild it from when a write is lost. |

## The rates, and the one that is ten times the others

Fetched from Cloudflare's KV pricing page today. All rates are per operation on a **per-key** basis; a bulk read of 50 keys is 50 billable reads.

| Operation | Workers Free | Workers Paid (included, then rate) |
| --- | --- | --- |
| Read | 100,000 / day | 10 million / month, then $0.50 / million |
| Write | 1,000 / day | 1 million / month, then $5.00 / million |
| Delete | 1,000 / day | 1 million / month, then $5.00 / million |
| List | 1,000 / day | 1 million / month, then $5.00 / million |
| Stored data | 1 GB | 1 GB, then $0.50 / GB-month |

Two consequences worth stating flatly. **A write costs the same as ten reads.** And **a miss is billable**: "All operations incur charges, including fetches for non-existent keys that return a null (Workers API) or HTTP 404 (REST API)." A cache-aside pattern that checks KV before hitting a database pays for every check, hit or miss. Egress is free.

Free-plan writes are the real cliff. One thousand writes a day is roughly one write every ninety seconds, sustained. Any per-request write pattern exhausts it before lunch.

[[widget:1]]

## kondro's 2021 arithmetic still prices out correctly in 2026

Five years ago, on a Hacker News thread about R2 pricing, a commenter laid out the KV objection:

> Workers KV is also eventually-consistent with no guarantee of read-after-write, which is a pretty big limitation compared to alternatives (S3 even has immediately-consistent list operations now after write).

The same comment put KV at $5 per million writes and $0.50 per million reads, called the reads pricier than S3's, and set that against Durable Object storage at $1 per million 4 KB writes with the Durable Object runtime cost stacked on top. Checked against today's published pages:

| kondro's 2021 figure | Published rate, July 2026 | Verdict |
| --- | --- | --- |
| KV writes $5 / million | $5.00 / million | Unchanged |
| KV reads $0.50 / million | $0.50 / million | Unchanged |
| KV reads pricier per read than S3 | S3 Standard GET is "$0.0004 per 1,000 requests" = $0.40 / million | Still true. KV reads cost 25% more per operation. |
| Durable Object storage $1 / million writes | SQLite-backed Durable Object storage: $1.00 / million rows written, first 50 million / month included | Same rate, and the free allowance is now fifty times KV's |
| Durable Object runtime cost on top | $0.15 / million requests plus $12.50 / million GB-s of duration | Still stacked, and still the reason KV wins on pure read serving |

The one number that moved in KV's favour is nothing to do with KV: Durable Object storage now includes 50 million row writes a month against KV's 1 million. For a write-heavy key, a Durable Object is now cheaper *and* correct.

R2 is the other comparison people make and get wrong in KV's favour. R2 Class B operations — the reads — are **$0.36 per million**, cheaper than KV's $0.50, with 10 million a month free and 10 GB of storage free against KV's 1 GB. R2 loses on latency, not on price.

## Bounding writes by putting the edge cache in front of KV

The write rate, not the read rate, is what turns a KV bill into a surprise. An operator running a share-link backend described the defence, in a thread about a Durable Object alarm loop that had burned $34,000 in eight days:

> The key property is that caches.default with Cache-Control: max-age=3600 becomes a natural throttle — at most 24 cache misses per day per key, so KV writes are bounded by (keys × 24) regardless of traffic.

The mechanism, step by step:

1. The Worker checks `caches.default` first. A hit returns without touching KV at all — no read charge, no write charge.
2. Only a miss reaches KV. Only a miss can trigger the refresh write.
3. `Cache-Control: max-age=3600` means a given key can only miss once an hour per cache location.
4. Therefore the *write* count per key is bounded by the number of cache expiries, not by the number of requests. Traffic can multiply by a thousand and the write bill does not move.

**What it costs you:** freshness. A value written now is invisible behind that cache for up to an hour, on top of KV's own propagation window. You are choosing a bounded bill over a bounded staleness, and you cannot have both.

This codebase runs the same pattern with a shorter window. `functions/_middleware.js` sets `LASTGOOD_REFRESH_MS = 120000` and `refreshLastGood()` returns early when the stored snapshot is younger than that, so any one path writes its snapshot at most once per two minutes no matter how many misses arrive. The edge cache in front carries `public, max-age=120, s-maxage=600, stale-while-revalidate=86400` for article pages. The measured result is in the last section: 12,231 writes a day across 6,568 snapshot keys, against a theoretical ceiling of 6,568 × 720 = 4.7 million.

## The per-key boundaries, and the error you get at each one

| Limit | Value | What happens at the boundary |
| --- | --- | --- |
| Key size | 512 bytes | The operation is rejected. Long composite keys are the usual cause. |
| Value size | 25 MiB | Write rejected. Anything approaching this belongs in [R2](/a/cloudflare-os-r2). |
| Metadata size | 1024 bytes, serialized JSON | Write rejected. Metadata rides along with `list()` results, which is why it is worth keeping small deliberately. |
| Writes to the same key | 1 per second, free and paid alike | Excess writes fail. This is a hard rate limit, not a billing threshold. |
| Operations per Worker invocation | 1,000 | A bulk request counts as one. |
| `expirationTtl` minimum | 60 seconds | Shorter values are rejected. A sub-minute lease is not expressible. |
| `cacheTtl` minimum | 30 seconds | Below this the parameter is refused. |
| Namespaces per account | 1,000 | — |

The key-size limit is the one that bites in production because it fails late and looks like something else. A pull request against Cloudflare's own `vinext` framework describes it exactly:

> When the assembled key exceeds Cloudflare KV's 512-byte key limit, `handler.get` throws a 414 **before** the wrapped function runs — so control-flow signals like `notFound()`/`redirect()` never fire, and the user sees a generic 200 error boundary instead of a 404.

Their fix is the one to copy: budget for your prefix (they used 480 bytes to leave room for `<appPrefix>:cache:`), keep short keys verbatim so they stay debuggable, and hash only the overflowing part.

## An eventually-consistent store cannot hold a lock, and this application's locks are only safe because nobody is racing

The honest answer first. A lock needs compare-and-set: test that nobody holds it and take it, atomically, with no window between the test and the take. KV has no such primitive. `get()` then `put()` is two operations with a gap, and the reference already told you what happens in that gap — concurrent writes to the same key overwrite one another, last write wins, no error returned to the loser.

Three KV locks run in this application, all with the same shape:

- **`locks:deploy:loop-safe-miscsubjects`** — `functions/_lib/fn_runners.js`, the `deployLease` runner. Reads the key, returns `ERR:deploy_lease:held:` if a live lease exists, otherwise writes a lease with a random `nonce` and `expirationTtl: 1800`. Release requires presenting the matching nonce, so a stale holder cannot free somebody else's lease. `scripts/ship.mjs` takes this lease before every deploy.
- **`selftest:lock`** — `functions/api/selftest.js`. Same read-then-write, `expirationTtl: 1800`, with a 1,500,000 ms staleness window on the stored timestamp so an abandoned run does not block the next one forever.
- **`fclaim:*`** — advisory file claims so two coding agents do not edit the same file, default lease 90 minutes.

Each of these is a genuine race. Two `acquire` calls landing inside the same second both read no lease, both write, and the second write wins silently. What makes the pattern survivable here, and the condition must be said out loud:

**These locks are safe only because contention is near zero.** A deploy happens a few times a day, initiated by a human or one agent. A self-test run is a scheduled singleton. Two agents claiming the same file inside the same second is a coincidence, not a workload. Change any of those assumptions — a deploy fired by webhook on every push, a self-test on a one-minute cron — and the lock stops working, quietly, with no error to tell you.

The codebase already contains the correction for the case where contention is real. `functions/_lib/idem_claim.js` guards invoke idempotency, where duplicate parallel calls are the normal case rather than a coincidence, and its opening comment records why it is not in KV:

> KV get→fire→put races: parallel identical calls all miss, all fire.

It uses `INSERT OR IGNORE` on a D1 table instead, where the primary key does the atomic test-and-set that KV cannot. That is the rule generalised: **if two writers can plausibly arrive together, the lock goes in D1 or a Durable Object, not KV.** Cloudflare's own guidance says the same thing — "KV is not ideal for applications where you need support for atomic operations or where values must be read and written in a single transaction."

[[widget:2]]

## The topology teams settle on: authority elsewhere, KV as the replicated read copy

Asked how they ran a global read path, one operator described the shape that keeps recurring:

> Cloudflare Workers KV has the simplest model, with a central-db that transparently and eventually only replicates read-only, hot-data specific to a DC but writes continue to incur heavy penalty

Their production system used DynamoDB in a single region as the source of truth, DynamoDB Streams pushing changes into Workers KV, and reads served from KV at the edge. Writes never touched KV directly. The reasons they gave were operations per second, cost and latency — and avoiding lock-in.

The generalised topology, and it is the one to copy:

1. **Authority** — a store with transactions: D1, a Durable Object, Postgres behind Hyperdrive, DynamoDB. All writes land here and here only.
2. **Propagation** — a change feed, a queue, or the write path itself pushes the new value into KV as a side effect. One writer per key, which is exactly what the reference recommends: *"It is a common pattern to write data from a single process with Wrangler, Durable Objects, or the API. This avoids competing concurrent writes because of the single stream."*
3. **Read** — every edge read hits KV. It is allowed to be a minute stale because the authority, not KV, is what anybody reconciles against.

Two field reports bracket the tradeoff. On the positive side, the author of an edge feature-flag system:

> I mostly use KV for storing flags specific to each project (which gets replicated automatically). Everything else goes to D1 (replication isn't needed here).

On the negative side, the bind that pushes people into KV whether it fits or not:

> You can use KV, with its trade-off of eventual consistency, or use something like FaunaDB or Firebase, but that means that the request has to wait for the request to the backing service.

Both are true at once. KV is the only storage on the platform that is already next to the Worker; everything else is a network hop. That is the whole reason people put things in it that do not belong there.

And a measured case of KV in the cache role paying off: an operator repeatedly tripping D1's 5 million daily row-read limit put a KV layer in front and reported back a week later — *"I implemented KV-layered caching"* — with reads down more than 80% and back under the limit. That is KV doing the job it is for. See [D1 in this stack](/a/cloudflare-os-d1) for the read-accounting model that makes those limits bite.

## Where each kind of state belongs

| If the state is… | KV | [D1](/a/cloudflare-os-d1) | [R2](/a/cloudflare-os-r2) | Durable Object storage | Cache API |
| --- | --- | --- | --- | --- | --- |
| Read from everywhere, written rarely, seconds of staleness fine | **Use this** | Slower reads, and rows read are metered | Higher latency, cheaper per read | Single-location reads | Not durable |
| Relational, queried by more than a key | No | **Use this** | No | Only if scoped to one object | No |
| Large bytes: images, video, archives | No — 25 MiB ceiling | No | **Use this** — free egress, $0.015/GB-month | No | No |
| Coordination, counters, anything atomic | **Never** | Workable via `INSERT OR IGNORE` | No | **Use this** — single-threaded, transactional | No |
| Per-request ephemeral output, regenerable | Wasteful — pays a write | No | No | No | **Use this** — free, per-location, non-durable |
| The source of truth for money or identity | **Never** | Yes | Yes for blobs | Yes | Never |
| Sixty-second global propagation is unacceptable | No | Yes | Yes | Yes | Yes, per location |

The Cache API row deserves its own sentence because it is the cheapest option on the table and the most often skipped: `caches.default` costs nothing per operation, is not durable, and is scoped to one Cloudflare location. Put it in front of KV, as above, and it is what bounds the write bill.

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| A value written a second ago reads as the old one, but only for some users | The reading location has a cached copy, or a cached negative lookup, from before the write | Wait out the 60-second window, or lower `cacheTtl`, or read from the authority instead of KV on the path that needs freshness |
| A key you just created reads as `null` in one region | Negative lookups are cached the same as values | Do not pre-read a key before writing it. If a probe is unavoidable, treat `null` as unknown, not absent |
| `handler.get` throws a **414**, and the framework's `notFound()` never runs | Assembled key exceeded 512 bytes | Budget for the prefix, keep short keys verbatim, hash the overflow |
| Writes silently stop landing on one key | 1 write per second per key, free and paid | Spread across discrete keys, or move that key to a Durable Object |
| The bill is dominated by an operation nobody thought about | Writes are $5.00 / million against reads at $0.50 | Put the Cache API in front so writes are bounded by cache expiries, not by traffic |
| Two processes both believe they hold the lock | `get()` then `put()` is not atomic; last write wins with no error | Move the lock to D1 `INSERT OR IGNORE` or a Durable Object |
| Free plan stops accepting writes mid-afternoon | 1,000 writes/day, reset 00:00 UTC | Batch, throttle behind a cache, or move to the paid plan |
| `expirationTtl: 30` rejected | Minimum is 60 seconds | Store the intended expiry inside the value and check it on read |

## Measured on this account today

Five measurements taken against the live namespace bound as `KV` in `wrangler.toml`. Account id and namespace ids are redacted below; substitute your own. The consistency probe wrote two obviously-named temporary keys, `tmp_consistency_probe_20260725` and `tmp_consistency_probe_b_20260725`, and both were deleted afterwards and verified gone (HTTP 404).

**1. Namespaces on the account — 6.**

```
npx wrangler kv namespace list
```

**2. Keys in the production namespace — 6,773, of which 6,568 are page snapshots.**

```
npx wrangler kv key list --namespace-id <NAMESPACE_ID> --remote > keys.json
python3 -c "import json;d=json.load(open('keys.json'));print(len(d))"
```

Prefix breakdown: `lastgood:` 6,568, `sync:` 35, `trail:` 33, `share_use:` 25, `mcp_oauth:` 18, `idem:` 6, then singletons. The longest key name measured **110 bytes** against the 512-byte limit.

**3. Stored bytes — 164.70 MB across the 1,041 snapshot keys that carry size metadata.** `refreshLastGood()` writes `{ts, bytes, ct}` as KV metadata, so `list()` returns the size of every value it wrote without reading any of them.

```
python3 -c "import json;d=json.load(open('keys.json'));b=[k['metadata']['bytes'] for k in d if k.get('metadata',{}).get('bytes')];print(len(b),sum(b),max(b))"
```

Median value 155,154 bytes, largest 2,140,072 bytes — 8% of the 25 MiB ceiling. Extrapolating that mean across all 6,568 snapshot keys puts the namespace at roughly **1.01 GB**, which is the 1 GB included allowance almost exactly; the overage at $0.50/GB-month is about half a cent. Treat the extrapolation as an estimate: the 5,527 older keys without metadata were not measured.

**4. Seven days of real operations — 899,100 reads, 85,620 writes, 740 deletes, 160 lists.** From Cloudflare's GraphQL analytics API, 2026-07-19 to 2026-07-26.

```
POST https://api.cloudflare.com/client/v4/graphql
{"query":"query { viewer { accounts(filter: {accountTag: \"<ACCOUNT_ID>\"}) {
  kvOperationsAdaptiveGroups(limit: 100, filter: {
    datetime_geq: \"2026-07-19T00:00:00Z\", datetime_leq: \"2026-07-26T00:00:00Z\",
    namespaceId: \"<NAMESPACE_ID>\"}) { sum { requests } dimensions { actionType } } } } }"}
```

The arithmetic that matters:

| Operation | 7-day count | Rate | Gross at list rates |
| --- | --- | --- | --- |
| Read | 899,100 | $0.50 / million | $0.4496 |
| Write | 85,620 | $5.00 / million | $0.4281 |
| Delete | 740 | $5.00 / million | $0.0037 |
| List | 160 | $5.00 / million | $0.0008 |
| **Total** | **985,620** | — | **$0.8822** |

Writes are **8.7% of the operations and 48.5% of the gross cost**. Extrapolated to a month: 3.85 million reads against the 10 million included, and 366,943 writes against the 1 million included — so the actual invoice line is **$0.00**. The write allowance is the binding constraint, with 2.7× headroom: 12,231 writes a day today, 33,333 a day before the meter starts.

[[widget:3]]

**5. Write, then read, and time the gap — visible in 0.21 s and 0.30 s across two trials.**

```
# seed the negative lookup at the reading location
for i in $(seq 1 6); do curl -s -o /dev/null -w "%{http_code} " \
  "https://miscsubjects.com/api/kv?key=tmp_consistency_probe_b_20260725" \
  -H "x-terminal-key: $TERMINAL_KEY"; sleep 2; done       # 404 404 404 404 404 404

npx wrangler kv key put tmp_consistency_probe_b_20260725 probe-b \
  --namespace-id <NAMESPACE_ID> --remote                   # real 1.14s

# poll every 0.5s until it appears
for i in $(seq 1 200); do code=$(curl -s -o /tmp/pb.txt -w "%{http_code}" \
  "https://miscsubjects.com/api/kv?key=tmp_consistency_probe_b_20260725" \
  -H "x-terminal-key: $TERMINAL_KEY"); \
  [ "$code" = "200" ] && break; sleep 0.5; done            # t+0.21s VISIBLE probe-b
```

Both trials converged in well under a second, including the trial that deliberately seeded six cached negative lookups first. **This does not demonstrate read-after-write consistency and must not be read as one.** It measures one reading location, close to the writer, twice. The documented window is a worst case, and the reference says explicitly that even same-location visibility "is not guaranteed". A system that happens to converge fast today is not a system you can design against.

Ten repeat reads of the same key through the deployed Worker, end to end over HTTPS from a laptop: minimum 136 ms, median 202 ms, maximum 260 ms. Almost all of that is network round trip, not KV — Cloudflare's own instrumentation puts the 90th percentile of KV Worker invocations "in less than 12 ms", and reports that the hottest 0.03% of keys, which serve over 40% of global KV requests, "resolve in under a millisecond".

An independent benchmark run from Cloudflare's Washington DC location (150 samples per metric, KV through the binding against Upstash Redis over HTTPS, same Worker, same request) put KV's hot read at **2.6 ms p50** — twice as fast as the competitor — and KV's single write at **171.8 ms p50**, twenty-eight times slower. That single pair of numbers is the whole argument of this page in measured form: KV's reads are the best on the platform and its writes are the worst.

For where KV sits among the other bindings in this stack, see [the Cloudflare stack index](/a/cloudflare-os), [Workers as the runtime](/a/cloudflare-os-workers) and [D1 as the relational store](/a/cloudflare-os-d1).

## The next read-only inventory still shows snapshots dominating the namespace

Wrangler 4.103.0 listed the production namespace at `2026-07-26T05:45:59.424Z`. Listing reads namespace metadata; it did not write, delete or fetch any value.

| Fresh check | Result |
| --- | ---: |
| Namespaces on the account | 6 |
| Keys in the production namespace | 6,767 |
| `lastgood:` snapshot keys | 6,568 |
| Longest key name | 110 bytes of the 512-byte limit |
| Keys carrying byte-count metadata | 1,046 |
| Bytes recorded by that metadata | 175,859,336 |
| Largest recorded value | 2,140,072 bytes |

Largest prefix groups: `lastgood:` 6,568 · `(singleton)` 54 · `sync:` 35 · `trail:` 33 · `share_use:` 25 · `mcp_oauth:` 18. The inventory reproduces the architectural claim directly: 97% of all keys are regenerable `lastgood:` page snapshots, not transactional state.

Run the same inventory without exposing the namespace id in a transcript:

```bash
npx wrangler kv namespace list
npx wrangler kv key list --namespace-id <NAMESPACE_ID> --remote > keys.json
python3 -c "import json; d=json.load(open('keys.json')); print(len(d), max(len(k['name'].encode()) for k in d))"
```

The first number is the key count. The second is the longest key name in bytes.

## Sources

1. How Workers KV works — https://developers.cloudflare.com/kv/concepts/how-kv-works/
2. Read key-value pairs — https://developers.cloudflare.com/kv/api/read-key-value-pairs/
3. Write key-value pairs — https://developers.cloudflare.com/kv/api/write-key-value-pairs/
4. Workers KV limits — https://developers.cloudflare.com/kv/platform/limits/
5. Workers KV pricing — https://developers.cloudflare.com/kv/platform/pricing/
6. Workers Cache API — https://developers.cloudflare.com/workers/runtime-apis/cache/
7. Workers storage options — https://developers.cloudflare.com/workers/platform/storage-options/
8. Cloudflare's Workers KV latency measurements — https://blog.cloudflare.com/faster-workers-kv/
9. vinext fix for KV's 512-byte cache-key limit — https://github.com/cloudflare/vinext/pull/2606
10. Cloudflare documentation clarification for KV consistency — https://github.com/cloudflare/cloudflare-docs/pull/2678
11. Upstash Redis versus Cloudflare KV benchmark — https://upstash.com/blog/upstash-redis-vs-cloudflare-kv
12. OAuth for all — https://news.ycombinator.com/item?id=48672342
13. A bit of math around Cloudflare's R2 pricing model — https://news.ycombinator.com/item?id=28703233
14. Launch HN: Fly.io (YC W20) – Deploy app servers close to your users — https://news.ycombinator.com/item?id=22644115
15. Reality Check for Cloudflare Wasm Workers and Rust — https://news.ycombinator.com/item?id=28581040
16. Durable Object alarm loop: $34k in 8 days, zero users, no platform warning — https://news.ycombinator.com/item?id=47917107
17. Show HN: An edge first feature flag implementation on Cloudflare — https://news.ycombinator.com/item?id=42531229
18. Fresh first-party KV namespace inventory — https://miscsubjects.com/api/articles/cloudflare-os-kv
19. First-party seven-day KV operations receipt — https://miscsubjects.com/api/articles/cloudflare-os-kv
20. First-party KV visibility probe — https://miscsubjects.com/api/articles/cloudflare-os-kv
21. First-party KV-lock code audit — https://miscsubjects.com/api/articles/cloudflare-os-kv
22. First-party snapshot metadata inventory — https://miscsubjects.com/api/articles/cloudflare-os-kv


---

# D1 bills rows, not queries, and serial round trips decide the architecture

slug: cloudflare-os-d1 · https://miscsubjects.com/a/cloudflare-os-d1 · tags: cloudflare, architecture, d1, cloudflare-os, sqlite, database, durable-objects, performance, migrations · updated 2026-07-26T03:59:21.950Z

D1 is Cloudflare's managed SQLite. Bind a database to a Worker in `wrangler.toml`, get `env.DB`, write ordinary SQL. No connection string, no pool, no instance to size. That pitch is accurate.

The shape underneath is what decides whether you should build on it: a single SQLite file inside a single Durable Object in a single Cloudflare location, billed by the row rather than by the query, capped at 10 GB per database and 2,000,000 bytes per stored value. Every surprise below follows from one of those four facts.

Siblings: [the platform index](/a/cloudflare-os), [Workers and Durable Objects](/a/cloudflare-os-workers), [R2 for the fields that do not fit](/a/cloudflare-os-r2).

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Cloudflare's own Workers architect calls D1 a wrapper

Kenton Varda, who built the Workers runtime, wrote this on Hacker News in June 2026:

> I'll let you in on a sort of dirty secret:
>
> It's almost always better to use Durable Objects storage, rather than D1. Even if you only want a single global database, it's better to implement that as a singleton Durable Object, than by using D1. Because that's all D1 itself actually is: a singleton Durable Object that exposes an API to its SQLite database. It's just a wrapper.

His decision rule, in the same comment:

> If your app does no more than one DB query per request, then D1 is fine: the Worker runs near the end user, and talks over the long-haul network to D1 just once. Whereas with Durable Objects, your Worker would talk over the long-haul network to the Durable Object. No difference.
>
> But if your app ever does two or more queries in series for a single request, then Durable Objects becomes vastly better, because you get to move that query-chaining code to happen directly where the database lives, rather than have multiple round trips.

And the reason D1 exists at all: "Really, though, the only reason D1 exists is for comfort. Once you know how to use Durable Objects, there's no reason to use D1." He names one exception — D1's read replication is not yet available to raw Durable Objects.

A **Durable Object** is a single-instance JavaScript class with private storage, addressed by name, that Cloudflare guarantees exists exactly once globally. A SQLite-backed one carries its own embedded SQLite database with the same SQL limits as D1, and your code runs in the same process as it — `sql.exec()` is a local function call, not a network request. That is the entire latency difference.

**Verdict.** Choose D1 when all three hold: the data is one global relational set, the request path makes one or two queries, and you want the operational surface D1 has and raw Durable Objects do not — `wrangler d1 execute` against production, versioned migration files, Time Travel point-in-time restore, and read replicas. Choose a SQLite Durable Object when the data partitions naturally per user, tenant, room or document, or when a single request chains three or more dependent queries. Those two rules cover almost every case; when they conflict, the query-chaining rule wins, because round trips are the thing you cannot optimise away later.

## The limits, fetched today, are the actual specification

Every number below is from `https://developers.cloudflare.com/d1/platform/limits/`, last updated 21 April 2026 per the page itself.

| Limit | Workers Paid | Workers Free |
| --- | --- | --- |
| Databases per account | 50,000 (raisable by request) | 10 |
| Maximum database size | 10 GB — cannot be raised | 500 MB |
| Maximum storage per account | 1 TB (raisable by request) | 5 GB |
| Time Travel window | 30 days | 7 days |
| Queries per Worker invocation | 1,000 | 50 |
| Columns per table | 100 | 100 |
| Rows per table | Unlimited within the size cap | Unlimited within the size cap |
| Maximum string, BLOB or table row size | **2,000,000 bytes** | 2,000,000 bytes |
| Maximum SQL statement length | **100,000 bytes** | 100,000 bytes |
| Maximum bound parameters per query | **100** | 100 |
| Maximum arguments per SQL function | 32 | 32 |
| Bytes in a `LIKE` or `GLOB` pattern | 50 | 50 |
| Maximum SQL query duration | 30 seconds | 30 seconds |
| Simultaneous D1 connections per Worker invocation | 6 | 6 |
| Rows read included | 25 billion / month, then $0.001 per million | 5 million / day, hard stop |
| Rows written included | 50 million / month, then $1.00 per million | 100,000 / day, hard stop |
| Storage included | 5 GB, then $0.75 per GB-month | 5 GB total |

Two of these get their own sections below: the 2,000,000-byte value cap, and rows as the billing unit. Three more matter immediately. **The 10 GB cap cannot be raised** — the docs say so in a caution box. **Each database is single-threaded**, so throughput is `1 / average query duration`: 1 ms queries give roughly 1,000 per second, 100 ms queries give 10. **Batch limits apply per statement**, not per batch, so a `db.batch()` of 40 statements can carry 40 × 100 KB of SQL.

## One undocumented ceiling: five terms in a compound SELECT

Building a table inventory with `SELECT 'x' t, COUNT(*) n FROM x UNION ALL …` across 89 tables failed immediately:

```
too many terms in compound SELECT: SQLITE_ERROR [code: 7500]
```

Bisecting against production found the number. Five `UNION ALL` terms succeed. Six fail.

```bash
# 5 terms — succeeds.  6 terms — SQLITE_ERROR 7500.
npx wrangler d1 execute <DB_NAME> --remote \
  --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"
```

Upstream SQLite defaults `SQLITE_MAX_COMPOUND_SELECT` to 500. D1 answers at 5, and this is not on the limits page. If anything generates SQL for you — an ORM, a reporting layer, a tool emitting multi-row `VALUES` as unions — chunk at five.

`dbstat`, the virtual table that reports per-table page usage, is also compiled out: `no such table: dbstat: SQLITE_ERROR [code: 7500]`. Per-table size has to be estimated with `SUM(LENGTH(col))`.

## Rows are the billing unit, and an unindexed predicate bills the whole table

You are not billed per query. You are billed for **rows read** — every row the engine had to scan, not the rows it returned. This is the most expensive misunderstanding available on D1.

The pricing page states it without hedging: a full scan of a 5,000-row table counts as 5,000 rows read, and "A query that filters on an unindexed column may return fewer rows to your Worker, but is still required to read (scan) more rows to determine which subset to return." Row size is irrelevant — "A row that is 1 KB and a row that is 100 KB both count as one row."

Rows written are simpler: `INSERT`, `UPDATE` and `DELETE` each cost one written row per row affected, and **an index adds a second written row** whenever the indexed column is part of the write.

### Where to see the number

Every D1 result carries a `meta` object. Read `meta.rows_read` and `meta.rows_written` in your Worker:

```js
const res = await env.DB.prepare("SELECT * FROM articles WHERE title = ?1")
  .bind(title).all();
console.log(res.meta.rows_read, res.meta.rows_written, res.meta.duration);
```

From the CLI, `--json` prints the same object:

```bash
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT COUNT(*) FROM articles WHERE title = 'D1 as the spine'"
```

Across the account it is in the Cloudflare dashboard at **your D1 database → Metrics → Row Metrics**, and in the GraphQL Analytics API.

### The same query, measured with and without an index

Run against a scratch table of 50,000 rows in this build's preview database, so nothing production was touched. Commands are in the measurement section at the bottom.

| Step | Result | `rows_read` | `rows_written` | Duration |
| --- | --- | --- | --- | --- |
| `SELECT COUNT(*) FROM d1_bench WHERE tenant = 'tenant-42'` — no index | 94 | **50,000** | 0 | 5.7362 ms |
| `CREATE INDEX d1_bench_tenant_idx ON d1_bench (tenant)` | — | 100,442 | 50,001 | 31.6053 ms |
| The identical `SELECT` again — index present | 94 | **95** | 0 | 0.2432 ms |
| `INSERT INTO d1_bench (tenant, payload) VALUES ('tenant-42','x')` | — | 0 | **2** | 0.28 ms |

Same query, same answer, 526 times fewer rows read and 23.6 times faster. The last row is the index's cost made visible: one insert now writes two rows, one to the table and one to the index, exactly as the pricing page says.

Production shows the same shape. `articles` has 2,186 rows and `slug` as its primary key:

| Query on `articles` (2,186 rows) | Result | `rows_read` | Duration |
| --- | --- | --- | --- |
| `WHERE slug = 'cloudflare-os-d1'` (indexed primary key) | 1 | **1** | 0.2003 ms |
| `WHERE title = 'D1 as the spine: two SQL databases, one of them append-only'` | 1 | **2,186** | 5.8578 ms |

On the largest table, `turn_costs` at 135,229 rows and indexed on `ts` only, one equality filter on the unindexed `key` column read **135,247 rows in 140.4919 ms** to return a count of 2,817.

### The arithmetic

Rows read: $0.001 per million after 25 billion included per month. Take the 50,000-row scan at one query per second — a modest API endpoint.

```
86,400 queries/day × 50,000 rows      = 4,320,000,000 rows read/day
4,320,000,000 × 30                    = 129,600,000,000 rows read/month
129,600,000,000 − 25,000,000,000 incl = 104,600,000,000 billable
104,600 millions × $0.001             = $104.60 / month
```

The indexed version of the identical query:

```
86,400 queries/day × 95 rows          = 8,208,000 rows read/day
8,208,000 × 30                        = 246,240,000 rows read/month
246,240,000 < 25,000,000,000 included = $0.00 / month
```

One `CREATE INDEX` is the difference between $104.60 and nothing. Its one-time write cost was 50,001 rows written — five cents at $1.00 per million.

On the free plan the same comparison is not a bill, it is an outage: 5,000,000 rows read per day, so **100 queries per day** at 50,000 rows each before D1 starts returning errors, against 52,631 at 95 rows each.

At the top end this is real money. A solo founder posted a postmortem in April 2026 after a Durable Object alarm loop — same rows-read meter — peaked at roughly 930 billion row reads per day and produced a $34,895 invoice with zero users:

> My DO agent's onStart() handler called this.ctx.storage.setAlarm() on every wake-up without checking whether an alarm was already scheduled.

No platform warning fired. Set a Cloudflare billing alert before you set anything else.

## SQLITE_TOOBIG is the 2,000,000-byte value cap, and serialization gets you there first

The exact string D1 surfaces is `D1_ERROR: string or blob too big`, with the underlying SQLite constant `SQLITE_TOOBIG`. It fires when any single string, BLOB or table row being written exceeds 2,000,000 bytes. It is not a database-size error and not a statement-length error — those have their own messages.

Two things make it arrive earlier than expected. The 100,000-byte statement cap means a large value can blow the statement before it blows the row. And the ceiling can be reached through serialization rather than raw size. A minimal reproduction filed against `cloudflare/workers-sdk` in May 2026:

> Workflows (local `wrangler dev`): a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed

200 KB of bytes failing while 2 MB of string succeeds is the tell: what is measured is the serialized representation, not your data.

### A worked example, with the numbers

The `articles` table stores each body as `TEXT` and everything non-scalar — claims, sources, widgets, the append-only revision chain — as one JSON `meta` column. One row broke.

`cognitive-stack-intro` carried 94 claims and 90 sources plus 24 inline revision snapshots, each holding a full copy of the body, claims and sources at that point in time. Stored `meta` reached **2,068,258 bytes**, 68,258 over the cap. Every write to that row — repair, claim, fill-slots — returned HTTP 500 with `D1_ERROR: string or blob too big`. Readable, permanently unwritable.

Three options, and only three:

| Option | What it does | Cost | When it is right |
| --- | --- | --- | --- |
| **Merge** | Fold the row into a related row and redirect | Loses the row's identity and its URL | The row was a near-duplicate anyway |
| **Prune** | Delete the least valuable fields until under 2 MB | Loses data permanently; the cap comes back as the row grows | The excess is genuinely junk and growth has stopped |
| **Offload to R2** | Move the heavy fields to object storage, keep a pointer plus a hash in D1 | One extra fetch when the heavy field is actually read | The data must be kept and the row keeps growing — the general answer |

Offload won, because pruning an append-only chain is the one thing the chain exists to prevent. `functions/_lib/revisions_r2.js` writes each full snapshot to R2 at `revisions/<slug>/<n>.json` and leaves a slim index entry in D1 carrying `n`, `ts`, `title`, `bytes`, `prev_hash`, `hash` and `r2_key`. Hash-chain verification still runs from D1 alone; the heavy content is fetched only when a specific revision is requested. `migrateRevisions()` runs at the top of every write, so any write heals a bloated row before adding to it.

`meta` fell from 2,068,258 bytes to 206,362 and the row returned HTTP 200. Measured again today it holds 1,990 bytes of body and 267,379 bytes of `meta` while carrying 80 claims, 112 sources and 43 revisions — more content than the version that could not be saved, in an eighth of the space. The object side is in [R2 as the place large fields go](/a/cloudflare-os-r2).

The same cap caught a bulk import from the other direction. Loading 663,115 iMessage rows hit `SQLITE_TOOBIG` on the **statement** cap rather than the row cap, because the importer packed many rows into one `INSERT`. Fix: byte-aware batching at ≤80,000 bytes per statement, plus a 20,000-character cap on any single message body after one arrived at 123 KB.

**The rule from both cases:** any column whose size is a function of history rather than of the schema belongs in R2 with a pointer in D1 — revision chains, audit payloads, uploaded documents, model transcripts. Keep the hash in D1 so the pointer is verifiable.

The largest row still in the table is 692,724 bytes of body plus 821,837 bytes of `meta` — **1,514,561 bytes, 76% of the cap**. It will need the same treatment.

## A transaction cannot span two requests, and the workaround is a deliberate parse error

D1 runs in auto-commit. The Workers Binding API documentation is plain: `batch()` "Sends multiple SQL statements inside a single call to the database… D1 operates in auto-commit. Our implementation guarantees that each statement in the list will execute and commit, sequentially, non-concurrently." Batched statements are a transaction — "If a statement in the sequence fails, then an error is returned for that specific statement, and it aborts or rolls back the entire sequence."

What is unavailable is holding a transaction open across two round trips: read, decide in JavaScript, write atomically against the state you read. An operator hit exactly this in April 2025:

> Another fun limitation is that a transaction cannot span multiple D1 requests, so you can't select from the database, execute application logic, and then write to the database in an atomic way. At most, you can combine multiple statements into a single batch request that is executed atomically.
>
> When I needed to ensure atomicity in such a multi-part "transaction", I ended up making a batch request, where the first statement in the batch checks a precondition and forces a JSON parsing error if the precondition is not met, aborting the rest of the batch statements.

The statement they used:

```sql
SELECT
  IIF(<precondition>, 1, json_extract("inconsistent", "$")) AS consistent
FROM ...
```

If the precondition holds, the statement returns 1. If not, `json_extract` is handed the invalid JSON literal `inconsistent`, throws, and the batch aborts before any write lands. Their own limit on it: "For anything more complex, one would probably need to create tables to store temporary values, and translate a lot of application logic into SQL statements to achieve atomicity."

The three honest options, ranked:

1. **Push the condition into SQL and use `batch()`.** Works when the precondition fits a `WHERE` or a `CASE`. Prefer `UPDATE … WHERE version = ?` over a poison-pill parse error: optimistic concurrency with a version column is the same guarantee written on purpose, and it reports failure as `changes: 0` rather than by throwing.
2. **Move the entity into a Durable Object.** Single-threaded by construction, so read-decide-write inside one method is atomic with no ceremony. This is where the constraint is pushing you.
3. **Use a database with real interactive transactions**, reached through Hyperdrive. Correct when the logic genuinely cannot be expressed in one round trip.

## Latency: two production reports, both true, measuring different things

The negative reports are specific and repeated. From someone running D1 in production across multiple projects for over a year:

> Using D1 in production for over an year on multiple projects - I can confirm response times to simple queries regularly take 400ms and beyond. On top there's constant network, connection and a plethora of internal errors.

From an evaluation that ended in rejection, with the comparison numbers:

> Using CF Workers + DigitalOcean Postgres, I was seeing query responses in the 50-100ms range.
>
> Using CF Workers + CF D1, I was seeing query responses in the 300-3000ms range.
>
> Both workers had Smart Placement enabled.

From a production user in April 2026, on reliability rather than latency:

> D1 reliability has been bad in our experience. We've had queries hanging on their internal network layer for several seconds, sometimes double digits over extended periods (on the order of weeks).

Against all of that, in the same thread as the 400 ms report:

> I am running 2 production apps on Cloudflare workers, both using D1 for primary storage. I found the performance ok, especially after enabling Smart Placement [1].

Neither side is wrong. They differ on two variables: how many D1 calls a single request makes, and whether the Worker ended up near the database.

**Smart Placement** is the mechanism in the middle. By default a Worker runs in the data centre nearest the user, which is the worst place to be if it then makes several round trips to a database in one fixed location. Smart Placement analyses a Worker's traffic and moves execution close to the backend instead. The documentation is precise about its boundaries: it takes up to 15 minutes to analyse a Worker after deployment; it needs consistent traffic from multiple locations to decide anything; it "only considers locations where the Worker has previously run", so it cannot place a Worker somewhere that never receives traffic; and it reverts itself when it makes things slower, which the docs put at fewer than 1% of Workers. Enable it in `wrangler.toml`:

```toml
[placement]
mode = "smart"
```

Its ceiling, from Varda in the same thread: "even if you have the Worker running in the same colo or even same machine as the D1 database, you're still speaking a network protocol to talk to it, serializing and deserializing data, switch contexts, etc. Directly invoking SQLite locally will still be orders of magnitude faster."

**Verdict.** Budget one long-haul round trip per D1 call, from wherever the Worker runs to wherever the database lives. A request making one query pays one, and Smart Placement will not help it — moving the Worker to the database just moves the same hop to the other end. A request making six sequential queries pays six, and that is where the 400 ms and 3-second numbers come from. Smart Placement collapses those six, which is the difference between the negative reports and the positive one. If the path is inherently chatty and cannot be flattened into one `batch()`, stop tuning D1 and move the entity into a Durable Object, where the queries stop crossing a network at all.

Two mitigations before concluding D1 is too slow. **Read replication** puts read-only copies in other regions, used through the Sessions API — `env.DB.withSession()` — which attaches a bookmark to each query so a session keeps sequential consistency even when different replicas serve it. Replicas cost nothing extra; you pay the same `rows_read`. Without the Sessions API it does nothing: "otherwise all queries will continue to be executed only by the primary database." **Caching** is the other: on this build most article reads never reach D1, because an edge cache or a KV snapshot answers first — [KV as the fast lane](/a/cloudflare-os-kv).

## Per-tenant sharding is documented, and impractical for the reason nobody mentions

Cloudflare's limits FAQ recommends the pattern: "D1 is designed for horizontal scale out across multiple, smaller (10 GB) databases, such as per-user, per-tenant or per-entity databases." 50,000 databases per account on the paid plan, raisable into the millions.

The count is not the problem. A Worker can only talk to a database bound to it at deploy time:

> It's not possible to set up per-user data in D1. Like in theory you probably could, but the DX infrastructure to make it possible is non-existent - you have to explicitly bind each database into your worker. At best you could try to manually shard data but that has a lot of drawbacks. Or maybe have the worker republish itself whenever a new user is registered? That seems super dangerous and unlikely to work in a concurrent fashion […] When I asked on Discord, someone from Cloudflare confirmed that DO is indeed the only way to do tenancy-based sharding

The limits page gives the hard number: bindings are roughly 150 bytes each inside a 1 MB script-metadata budget, so "approximately 5,000" D1 bindings per Worker script. The documented 50,000 databases and the reachable 5,000 are ten times apart, and every new tenant needs a redeploy.

**What to do instead.** Durable Objects address instances by name at runtime — `env.MY_DO.idFromName(tenantId)` — one binding for the class, unlimited instances behind it, each with its own 10 GB SQLite database and no per-class storage cap. Tenancy sharding without a deploy. Someone running it at scale, July 2026:

> We serve multi million MAU on sqlite orchestrated through durable objects. It's not the most complex thing in the world but it goes further than CRUD. It costs us such a small amount of money for what it does.

If you must stay on D1: bind a fixed number of databases up front and hash tenants into them, accepting that rebalancing means a migration. Dynamic database-per-tenant does not exist on D1 today.

## Migrations are ordered files, and `d1 execute` silently desynchronises them

Migrations are `.sql` files in `migrations/`, named with a leading sequence number and applied in filename order. Wrangler records what it applied in a `d1_migrations` table inside the database.

```bash
# 1. Create an empty, correctly-numbered file. Prints the path it created.
npx wrangler d1 migrations create loop-content-spine "add_tenant_index"
#    -> migrations/0331_add_tenant_index.sql

# 2. Write the SQL into that file. Forward-only; write it to be re-runnable.
#    CREATE INDEX IF NOT EXISTS idx_articles_register ON articles(register);

# 3. See exactly what would run, before it runs.
npx wrangler d1 migrations list loop-content-spine --remote

# 4. Apply to the preview database first.
npx wrangler d1 migrations apply loop-content-spine-preview --remote

# 5. Then production.
npx wrangler d1 migrations apply loop-content-spine --remote
```

Use the **database name**, not the binding name. The docs give the reason: "the binding name can change, whereas the database name cannot."

**There is no `down` migration.** The system supports create, list and apply — nothing else. Rolling back means one of two things:

```bash
# Option A — a forward migration that undoes the change. Preferred.
npx wrangler d1 migrations create loop-content-spine "drop_tenant_index"

# Option B — Time Travel, point-in-time restore, 30 days on Workers Paid.
npx wrangler d1 time-travel info loop-content-spine
# ⚠️ The current bookmark is '0000110b-000002cc-000050b4-90fa940d708157e29a40c704f1591c8e'
npx wrangler d1 time-travel restore loop-content-spine --bookmark=<BOOKMARK>
# or:  --timestamp=2026-07-25T00:00:00Z
```

Take the bookmark **before** you apply, not after you break something. Time Travel restores the whole database, so it is a blunt instrument for one bad table.

The failure mode this repository demonstrates is drift. There are 330 `.sql` files in `migrations/`. `d1_migrations` records 136 applied, most recently `0133_charlie_audit.sql`. `wrangler d1 migrations list` therefore reports 202 still to be applied — and nearly all of them already are, because those schema changes were pushed with `wrangler d1 execute --command "CREATE TABLE …"` instead of through the runner. Wrangler cannot know that. Running `apply` now would replay 202 files against a schema that already has them.

**How to avoid it:** never change schema with `d1 execute`. If you already have, insert the missing filenames into `d1_migrations` so the ledger matches reality, then resume using `apply`. Check they agree before every release:

```bash
ls migrations/*.sql | wc -l
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT COUNT(*) applied, MAX(name) latest FROM d1_migrations"
```

## Choosing between D1 and the three things it competes with

| | D1 | Durable Object + SQLite | Hyperdrive → Postgres/MySQL | Hosted database, direct |
| --- | --- | --- | --- | --- |
| What it is | Managed SQLite in one location, exposed over the network | Your code and an embedded SQLite file in the same process | Connection pooling and caching in front of your own regional database | A normal database reached over the internet |
| Query latency from a Worker | One long-haul round trip per call | Effectively zero once you are in the object | One round trip to the pooled connection, warm | Full connection setup plus round trip |
| Multi-query request | Pays N round trips; needs Smart Placement | Pays one hop total, then local calls | Pays N round trips but keeps the connection | Worst case |
| Transactions across app logic | No | Yes, single-threaded by construction | Yes, full interactive transactions | Yes |
| Per-tenant sharding | Not practically — bindings are static | Yes, `idFromName()` at runtime | Via your own schema | Via your own schema |
| Size ceiling | 10 GB per database, hard | 10 GB per object, unlimited objects | Whatever your database does | Whatever your database does |
| Read replicas | Yes, via the Sessions API | Not yet | Your database's own replicas | Your database's own replicas |
| Billing unit | Rows read and written | Rows read and written, plus object duration | Workers time; the database is billed separately | Database bill plus egress |
| Operational surface | `wrangler d1 execute`, migrations, Time Travel | You build it | Your existing tooling, unchanged | Your existing tooling |
| **Verdict** | One global relational set under 10 GB, ≤2 queries per request, and you want the CLI and migrations | Anything per-entity, or any chatty request path | You already have Postgres or MySQL and are not leaving it | Only if Hyperdrive cannot reach it |

Two mistakes to avoid: reaching for D1 because it is the dashboard default when the data is obviously per-user, and leaving Cloudflare over D1 latency when the fix was one binding change.

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `D1_ERROR: string or blob too big` | A single value or row exceeds 2,000,000 bytes | Offload the heavy field to R2 and keep a pointer plus hash in D1 |
| `string or blob too big` on a bulk insert | The statement, not the row, exceeded 100,000 bytes | Byte-aware batching; cap each statement at ~80,000 bytes |
| `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | More than 5 `UNION`/`UNION ALL` terms in one statement | Chunk generated SQL into groups of five |
| `no such table: dbstat` | The `dbstat` virtual table is not compiled into D1 | Estimate table size with `SUM(LENGTH(col))` |
| `D1 DB is overloaded. Requests queued for too long.` | Queries are slow and the single-threaded database has a full queue | Index the predicates; shorten each query; spread load; shard |
| `D1 DB is overloaded. Too many requests queued.` | Request rate exceeds `1 / query duration` | Same, plus read replicas via the Sessions API for read-heavy load |
| `Exceeded maximum DB size.` | The database passed 10 GB, which cannot be raised | Delete rows, or shard across databases |
| `Your account has exceeded D1's maximum account storage limit…` | All databases together passed the account cap | Delete unused databases or raise the account limit by request |
| `D1 DB exceeded its CPU time limit and was reset.` | One query scanned far too much — a huge table scan or a bulk import | Split into smaller shards; index the predicate |
| `D1 DB storage operation exceeded timeout which caused object to be reset.` | A single write touched gigabytes | Batch the write into chunks of ~1,000 rows |
| `D1 DB reset because its code was updated.` | Cloudflare restarted the Durable Object backing your database | Retry — it is transient and expected. Make writes idempotent |
| `Network connection lost.` / `Cannot resolve D1 DB due to transient issue on remote node.` | Transient network fault between Worker and database | Retry, but only if the query is idempotent |
| `D1_TYPE_ERROR` | A bound parameter was `undefined` | D1 does not accept `undefined`. Coerce to `null` |
| Bill far higher than query volume suggests | Unindexed predicates scanning whole tables | Read `meta.rows_read`; add an index; recheck |
| Queries fine locally, slow in production | Worker running near the user, database elsewhere, several round trips | Enable Smart Placement, or flatten into one `batch()`, or move to a Durable Object |

## Every measurement on this page, and the command that produced it

Taken 25 July 2026 against this build's production D1 databases with wrangler 4.103.0. Account id and database ids redacted; substitute your own database name. Reads only, except the scratch table, created and dropped in the **preview** database.

**1. Table inventory — 89 tables, 243,173 rows.** The five-term compound-SELECT ceiling forces chunks of five:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"

npx wrangler d1 execute <DB_NAME> --remote --json --command \
"SELECT 'turn_costs' t, COUNT(*) n FROM turn_costs UNION ALL SELECT 'log' t, COUNT(*) n FROM log UNION ALL SELECT 'imessages' t, COUNT(*) n FROM imessages UNION ALL SELECT 'leads' t, COUNT(*) n FROM leads UNION ALL SELECT 'articles' t, COUNT(*) n FROM articles"
```

Largest first: `turn_costs` 135,229 · `log` 59,164 · `imessages` 10,536 · `leads` 10,089 · `agent_turns` 6,844 · `tasks` 6,055 · `cc_turns` 2,296 · `articles` 2,186 · `pipeline` 2,058 · `directory` 892. Six tables are empty.

**2. Database size — 281,993,216 bytes on the content database, 1,062,027,264 bytes on the event log.** `meta.size_after` is returned on every query, so any read gives it:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json --command "SELECT 1"   # read meta.size_after
npx wrangler d1 execute <LEDGER_NAME> --remote --json --command "SELECT COUNT(*) FROM events"
```

The event log holds 400,907 rows at 1.062 GB — 10.6% of the 10 GB per-database ceiling and already past the 500 MB the free plan allows. Both databases together are 1.25 GB, inside the 5 GB included, so storage costs $0.00.

**3. Largest table by stored bytes — `articles`, 78,057,031 bytes.** `dbstat` is unavailable, so size is summed from the columns:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT SUM(LENGTH(COALESCE(body,''))+LENGTH(COALESCE(meta,''))) AS bytes FROM articles"

npx wrangler d1 execute <DB_NAME> --remote --json \
  --command "SELECT slug, LENGTH(COALESCE(body,'')) body_bytes, LENGTH(COALESCE(meta,'')) meta_bytes FROM articles ORDER BY (LENGTH(COALESCE(body,''))+LENGTH(COALESCE(meta,''))) DESC LIMIT 5"
```

The second query read 4,372 rows to sort 2,186 — an unindexed sort reads the table twice. Largest row: 692,724 + 821,837 = 1,514,561 bytes.

**4. Indexed versus unindexed, identical query.** Against the preview database only:

```bash
DB=<PREVIEW_DB_NAME>
npx wrangler d1 execute $DB --remote --command \
  "CREATE TABLE d1_bench (id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, payload TEXT)"

npx wrangler d1 execute $DB --remote --command \
"INSERT INTO d1_bench (tenant, payload) SELECT 'tenant-' || (abs(random()) % 500), hex(randomblob(32)) FROM (WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c WHERE x < 50000) SELECT x FROM c)"

npx wrangler d1 execute $DB --remote --json --command \
  "SELECT COUNT(*) AS n FROM d1_bench WHERE tenant = 'tenant-42'"        # rows_read 50000, 5.7362 ms

npx wrangler d1 execute $DB --remote --json --command \
  "CREATE INDEX d1_bench_tenant_idx ON d1_bench (tenant)"                # rows_written 50001

npx wrangler d1 execute $DB --remote --json --command \
  "SELECT COUNT(*) AS n FROM d1_bench WHERE tenant = 'tenant-42'"        # rows_read 95, 0.2432 ms

npx wrangler d1 execute $DB --remote --command "DROP TABLE d1_bench"
```

The recursive CTE is how you generate N rows in one statement without passing the 100,000-byte statement cap.

**5. The compound-SELECT ceiling.** Bisected with `SELECT 1 UNION ALL …` at 2, 5, 6, 8, 10, 15 and 20 terms. 2 and 5 succeed; 6 and above return `SQLITE_ERROR [code: 7500]`.

**6. Migration drift.** `ls migrations/*.sql | wc -l` → 330. `SELECT COUNT(*) applied, MAX(name) latest FROM d1_migrations` → 136, `0133_charlie_audit.sql`. `npx wrangler d1 migrations list <DB_NAME> --remote` → 202 listed as to be applied.

## A fresh read-only receipt reproduces the row meter and the five-term ceiling

Wrangler 4.103.0 ran seven read-only statements against the two production databases at `2026-07-26T05:38:25.854Z`. No table or row changed.

| Check | Result | `rows_read` | SQL duration |
| --- | --- | ---: | ---: |
| `SELECT COUNT(*) FROM articles` | 2,186 articles | 2,186 | 0.1973 ms |
| Primary-key lookup for `cloudflare-os-d1` | 1 row | 1 | 0.1485 ms |
| Equality lookup on the unindexed old title | 1 row | 2,186 | 5.8711 ms |
| Five `UNION ALL` terms | HTTP/API success; 5 rows returned | 0 | 0.1638 ms |
| Six `UNION ALL` terms | `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | — | — |
| `SELECT COUNT(*) FROM events` | 401,112 events; database size 1,062,916,096 bytes | 401,112 | 6.8717 ms |
| Migration ledger | 136 applied; latest `0133_charlie_audit.sql` | 136 | 3.2203 ms |

Run the same harmless checks with your database names:

```bash
npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT COUNT(*) AS articles FROM articles"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT slug FROM articles WHERE slug='cloudflare-os-d1'"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"

npx wrangler d1 execute <DB_NAME> --remote --json   --command "SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1"
```

The fifth query is expected to fail. That failure is the measurement: the same database accepted five compound terms and rejected six with code 7500.

## Sources

1. Cloudflare D1 overview — https://developers.cloudflare.com/d1/
2. D1 limits — https://developers.cloudflare.com/d1/platform/limits/
3. D1 pricing — https://developers.cloudflare.com/d1/platform/pricing/
4. D1Database API — https://developers.cloudflare.com/d1/worker-api/d1-database/
5. Debug D1 — https://developers.cloudflare.com/d1/observability/debug-d1/
6. Use indexes — https://developers.cloudflare.com/d1/best-practices/use-indexes/
7. Use read replication — https://developers.cloudflare.com/d1/best-practices/read-replication/
8. Smart Placement — https://developers.cloudflare.com/workers/configuration/smart-placement/
9. D1 migrations — https://developers.cloudflare.com/d1/reference/migrations/
10. D1 Time Travel — https://developers.cloudflare.com/d1/reference/time-travel/
11. SQLite-backed Durable Objects — https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/
12. Cloudflare Hyperdrive — https://developers.cloudflare.com/hyperdrive/
13. Cloudflare workers-sdk — https://github.com/cloudflare/workers-sdk
14. Independent Workers database latency comparison — https://news.ycombinator.com/item?id=43607264
15. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43607561
16. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43607264
17. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43614249
18. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43610222
19. Cloudflare's AI Platform: an inference layer designed for agents — https://news.ycombinator.com/item?id=47797766
20. Workflows (local `wrangler dev`): a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed — https://github.com/cloudflare/workers-sdk/issues/14101
21. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43608066
22. Temporary Cloudflare accounts for AI agents — https://news.ycombinator.com/item?id=48611834
23. SQLite Is All You Need — https://news.ycombinator.com/item?id=48946048
24. Durable Object alarm loop: $34k in 8 days, zero users, no platform warning — https://news.ycombinator.com/item?id=47787042
25. Fresh first-party indexed versus unindexed lookup receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
26. Fresh first-party compound SELECT receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
27. Fresh first-party event-ledger size receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
28. Fresh first-party migration-ledger receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
29. First-party 50,000-row index benchmark receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1
30. First-party D1-to-R2 revision offload receipt — https://miscsubjects.com/api/articles/cloudflare-os-d1


---

# The Cloudflare OS: one account running an entire build

slug: cloudflare-os · https://miscsubjects.com/a/cloudflare-os · tags: cloudflare, architecture, cloudflare-os · updated 2026-07-26T03:59:19.580Z

One Cloudflare account runs this application end to end: the pages, the API, the relational database, the append-only event store, the object store, the queue, the scheduled jobs, the headless browser, the model calls and outbound mail. No second host, no virtual machine, no container.

A **binding** is a line in a Cloudflare configuration file that attaches a resource to code and names the property the code reaches it by. `env.DB` is a binding. The resource behind it has an account-scoped identifier; the running code never sees that identifier, only the name. That indirection is why a preview deployment can point at a different database without one line of code changing.

Below: the complete binding list read out of the configuration files, the unit each one bills on, the arithmetic for a stated month, the failure that turns a hobby account into a five-figure invoice, and reports from people who ran this stack and stayed or left.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Nine bindings ship with the request layer

`wrangler.toml` at the repository root declares everything the page-and-API layer can touch. The account identifier and the workers.dev subdomain are redacted; nothing else is.

| Binding | Kind | What is behind it | Why this and not something else |
| --- | --- | --- | --- |
| `DB` | D1 database `loop-content-spine` | 91 tables: articles, the capability directory, agents, leads, tasks, sessions | Relational reads with joins and indexes. KV cannot filter; R2 cannot query |
| `LEDGER` | D1 database `loop-shared-events` | 12 tables, append-only: `events`, `invocations`, `capabilities`, hash-chain checkpoints | Separate database so a write-heavy audit trail cannot compete with page reads for the same storage limit |
| `KV` | Workers KV namespace `loop_content_kv` | 19 keys: OAuth handles, prompt settings, one cached audio blob | Config distributed to every location. Read-mostly, tolerant of eventual consistency |
| `R2` | R2 bucket `miscsubjects-ledger` | 645 objects, 12.3 MB: vendored docs, disclosure receipts, agent-turn archives | Bytes with no query on them. R2 charges nothing for egress; a database charges per row scanned |
| `AI` | Workers AI | Model inference invoked from a request | Inference without an outbound API key in the request path |
| `DIRECTORY_DO` | Durable Object class `DirectoryDO` in Worker `loop-safe-directory-do` | Slug registry plus an append-only mutation-intent log | Single strongly-consistent writer. D1 cannot hold a transaction across two requests, so the serialization point has to be an object |
| `TASKS` | Queue producer for `loop-tasks` | Work handed off so the reader is not kept waiting | A request has a wall-clock budget; a queue consumer has its own |
| `STORE` | Service binding to Worker `loop-safe-storage` | Bulk blobs in a second R2 bucket plus a D1 index over them | Keeps a second bucket and a second database out of the Pages project's binding list. Worker-to-worker, no public route |
| `META_BRIDGE` | Service binding to Worker `loop-meta-bridge` | The Meta Graph API, fronted | That Worker binds the Meta token by reference from Secrets Store. Nothing copies the token |

All nine are repeated under `[[env.preview.*]]` in the same file, with `DB` and `LEDGER` pointing at `loop-content-spine-preview` and `loop-shared-events-preview`. Pages environment overrides do not inherit: a binding declared once at the top level and not repeated under `env.preview` does not exist in a preview deployment at all. Two D1 identifiers differ; the other seven bindings are byte-identical repetitions. That duplication is the only thing stopping a pull-request preview from writing to production data.

[[embed:source:s1]]
[[embed:source:s2]]
[[embed:source:s3]]
[[embed:source:s4]]

## Seven more Workers hold what a request cannot

The Pages project is one deployment. Six sibling Workers carry the bindings a request-scoped runtime cannot own — scheduled execution, long jobs, durable state and mail — plus one that exists only to serve a single path.

| Worker | Bindings it owns | What it is for |
| --- | --- | --- |
| `loop-safe-sibling` | `AI`, `DB`, `KV`, `R2`, DO classes `ExpertDO` and `AgentDO`, Workflows `DELIVER_WF` and `SELFTEST_WF`, browser `MYBROWSER`, queue producer **and** consumer for `loop-tasks`, `send_email` binding `EMAIL`, cron `*/1 * * * *` and `0 4 * * *` | Everything on a clock, everything that survives a restart, everything that drives a browser |
| `loop-safe-directory-do` | `DB`, DO class `DirectoryDO`, SQLite migration `v1` | The one strongly-consistent writer for slugs |
| `loop-safe-storage` | R2 bucket `miscsubjects-store`, D1 `loop-storage-index` | Bulk blobs with a queryable index. `workers_dev = false`, reachable only via `STORE` |
| `loop-meta-bridge` | Secrets Store refs `META_ACCESS_TOKEN`, `META_BUSINESS_ID`, `META_API_VERSION` | Meta Graph reads only. `workers_dev = false` |
| `miscsubjects-mcp` | `DB`, `KV`, DO class `MiscsubjectsMCP` | Model Context Protocol server |
| `oip-peer` | KV namespace `oip-peer-store` | A second federation node on a second registrable domain |
| `miscsubjects-robots` | Route `miscsubjects.com/robots.txt` | One path, one Worker |

`DIRECTORY_DO` and `STORE` are ordering traps: each names another Worker by `script_name` or `service`, and if that Worker has never been deployed the next Pages deploy fails on the binding, not at the call site. The target has to exist before the pointer does.

[[embed:source:s42]]

## Secrets appear as names in code and as values nowhere in the repository

Sixty-eight uppercase environment names are referenced across the Functions code, of which 43 are credentials. They are Pages environment variables and Secrets Store references, never files in the repository. Regenerate the list:

```
grep -rhoE 'env\.[A-Z][A-Z0-9_]{3,}' functions --include='*.js' | sed 's/env\.//' | sort -u
```

Names only, values nowhere: `ADMIN_SESSION_SECRET`, `AIG_TOKEN`, `AIG_RUN_TOKEN`, `AIG_SHIM_TOKEN`, `ANTHROPIC_API_KEY`, `ARCADS_API_KEY`, `BLOOIO_API_KEY`, `CF_API_TOKEN`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_GLOBAL_KEY`, `GEMINI_API_KEY`, `GITHUB_TOKEN`, `GROK_API_KEY`, `KIMI_API_KEY`, `MCP_TOKEN`, `META_ACCESS_TOKEN`, `MOONSHOT_API_KEY`, `OPENAI_API_KEY`, `STORE_KEY`, `STRIPE_SECRET_KEY`, `TELEGRAM_BOT_TOKEN`, `TERMINAL_KEY`, `VAULT_UNLOCK_TOKEN`, `X_API_KEY`, `X_ACCESS_TOKEN`, and eighteen more of the same shape.

The comment in `wrangler.toml` records why these are plain Pages variables and not Secrets Store entries: wrangler 4.99 rejects `[[secrets_store_secrets]]` for a Pages project. The Meta bridge, which is a Worker and not Pages, does use Secrets Store — three references, `store_id` in the config, values never copied.

[[embed:source:s5]]

## Every binding bills on a different unit, and only three of them can run away

The Workers Paid plan is $5.00 a month minimum for the account, and that subscription covers Workers, Pages Functions, KV, Hyperdrive and Durable Objects usage up to the included amounts below. Rates as published on 2026-07-26.

| Binding kind | The unit that bills | Free plan | Paid plan |
| --- | --- | --- | --- |
| Workers / Pages Functions requests | one request, cache hits included | 100,000/day | 10M/month, then $0.30/M |
| Workers CPU time | CPU milliseconds burned, not wall clock | 10 ms/invocation | 30M CPU-ms/month, then $0.02/M |
| D1 rows read | rows a query **scans**, not rows it returns | 5M/day | 25 billion/month, then $0.001/M |
| D1 rows written | rows changed, plus one per index touched | 100,000/day | 50M/month, then $1.00/M |
| D1 storage | GB summed across every database on the account | 5 GB total | 5 GB, then $0.75/GB-month |
| KV reads | one key, including reads that return null | 100,000/day | 10M/month, then $0.50/M |
| KV writes / deletes / lists | one key, or one list call | 1,000/day each | 1M/month each, then $5.00/M |
| KV storage | GB stored | 1 GB | 1 GB, then $0.50/GB-month |
| R2 storage | GB-month, standard class | 10 GB-month/month | $0.015/GB-month |
| R2 Class A operations | writes and listings | 1M/month | $4.50/M |
| R2 Class B operations | reads | 10M/month | $0.36/M |
| R2 egress | bytes to the internet | free | free |
| Durable Object requests | HTTP, RPC sessions, WebSocket messages **and alarm invocations** | 100,000/day | 1M/month, then $0.15/M |
| Durable Object duration | gigabyte-seconds the object is resident | 13,000 GB-s/day | 400,000 GB-s/month, then $12.50/M GB-s |
| Durable Object SQLite rows | same counters and rates as D1 | 5M read / 100,000 written per day | 25 billion read / 50M written per month |
| Queues | one operation; write + read + delete is three | 10,000/day | 1M/month, then $0.40/M |
| Workflows steps | one persisted step | 3,000/day | 500,000/month, then $0.80 per extra 100,000 |
| Browser | browser-hours, plus concurrent browsers averaged monthly | 10 minutes/day | 10 hours/month then $0.09/hour; 10 concurrent then $2.00/browser |
| Email sending | outbound transactional mail | not available | included with Workers Paid; sending to verified destination addresses is free on all plans |

Three of those units are unbounded by traffic and therefore the ones to watch. **Durable Object requests** count alarm invocations, so a self-scheduling object bills whether or not anyone visits. **D1 rows read** counts a scan, so one missing index multiplies the bill by the size of the table. **Queue operations** count three per message, so a retry storm triples on a metric that is already tripled.

[[embed:source:s6]]
[[embed:source:s7]]
[[embed:source:s8]]
[[embed:source:s9]]
[[embed:source:s10]]
[[embed:source:s11]]
[[embed:source:s12]]
[[embed:source:s13]]
[[embed:source:s14]]

## One index turns 2,186 rows read into 1

The same lookup, run against the live production database twice on 2026-07-26, once through the slug index and once as a scan:

```
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT slug FROM articles WHERE slug='cloudflare-os'"
# "rows_read": 1, "duration": 0.1746

npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT slug FROM articles WHERE body LIKE '%DIRECTORY_DO%' LIMIT 5"
# "rows_read": 2186, "duration": 23.1205
```

Same table, same result column, 2,186 times the billable work and 132 times the latency. `LIMIT 5` does not help, because the limit applies to rows returned and the meter counts rows scanned. Every D1 query returns a `meta` object carrying `rows_read` and `rows_written`; that object, not the dashboard, is the cheapest place to catch a query that scans a table.

[[embed:source:s18]]
[[embed:source:s19]]
[[embed:source:s43]]

## The arithmetic for one month at one million page views

Assume one million requests a month against this application, four D1 queries per page render, all of them indexed lookups that read fewer than fifty rows each, one KV read per render for the settings blob, and the storage measured above.

```
Workers Paid subscription                                        $5.00
Requests   1,000,000  (10,000,000 included)                      $0.00
CPU time   1,000,000 x 7 ms = 7,000,000 CPU-ms
           (30,000,000 included)                                 $0.00
D1 rows read   1,000,000 x 4 queries x 50 rows = 200,000,000
           (25,000,000,000 included)                             $0.00
D1 storage 281,989,120 + 1,061,666,816 bytes = 1.34 GB
           (5 GB included)                                       $0.00
KV reads   1,000,000  (10,000,000 included)                      $0.00
R2 storage 12,261,706 bytes = 0.0123 GB  (10 GB-month included)  $0.00
Queues     assume 20,000 messages x 3 operations = 60,000
           (1,000,000 included)                                  $0.00
                                                          ------------
TOTAL                                                            $5.00
```

The included allowances are the point. At a million page views a month this application costs the subscription and nothing else. The same page views on the unindexed query above — 1,000,000 × 4 × 2,186 = 8,744,000,000 rows read — still land inside the 25 billion included, and at ten million page views become 87.44 billion rows, of which 62.44 billion bill at $0.001 per million: **$62.44**. A single missing index on a 2,186-row table is a $62 line item. On a table with a million rows it is a bill that needs its own meeting.

[[embed:source:s44]]

## $34,895 in eight days, with zero users and no alert

The failure that matters is not slow growth. It is a Durable Object scheduling its own wake-up.

A pre-launch solo founder posted the postmortem to Hacker News on 2026-04-16. His agent object called `setAlarm()` in `onStart()` without checking whether an alarm was already pending, so every wake-up scheduled another. Sixty-plus preview deployments each created independent object instances, each running its own copy of the loop. His timeline: began 3 April with zero prior Durable Object usage; peaked 4–5 April at roughly 930 billion row reads a day; found and fixed 11 April; invoice due 15 April, $34,895.

Work the peak day against the published rate. 930,000,000,000 ÷ 1,000,000 = 930,000 million-row units × $0.001 = **$930 for one day of row reads**. Eight days at that peak is $7,440. The post does not itemise the invoice, so the remaining ~$27,000 is Durable Object requests — one per alarm invocation, $0.15 per million — plus duration at $12.50 per million GB-seconds for objects that never went idle. Row reads do not explain the number. Alarms billing as requests do.

Why nothing warned him is the platform-level lesson, and the reason this sits on the index page rather than in the Durable Objects article: Workers Usage Notifications monitor CPU time. They do not monitor Durable Object row reads or writes, and there is no hard spending cap for Durable Object operations in the dashboard or in a wrangler file. Nothing was going to fire.

The fix from the same post is three lines — read `getAlarm()` first, call `setAlarm()` only if it is empty. The two structural fixes alongside it: strip Durable Object bindings out of preview environments entirely, and deploy a separate budget-monitor Worker as a kill switch, because the platform will not.

[[embed:source:s25]]

## Pick the binding by the job, not by the brand

| The job | The binding | Why, in one line | Depth |
| --- | --- | --- | --- |
| Relational data you filter, join and sort | D1 | The only binding running SQL over a schema you control | [D1](/a/cloudflare-os-d1) |
| Hot config read everywhere, written rarely | KV | Everywhere at once, eventually consistent, cheap reads and expensive writes | [KV](/a/cloudflare-os-kv) |
| Files, images, archives — bytes with no query | R2 | Free egress, $0.015 per GB-month, no per-row meter | [R2](/a/cloudflare-os-r2) |
| Serving pages and answering API calls | Pages Functions | One deployment for the page and its API, so no version skew | [Functions](/a/cloudflare-os-functions) |
| Long-lived state one caller at a time must own | Durable Objects | Single-threaded, own SQLite. The only strong-consistency primitive here | [Workers and DOs](/a/cloudflare-os-workers) |
| Work the reader must not wait for | Queues | Producer in the request, consumer with its own time budget | [Async](/a/cloudflare-os-async) |
| Multi-step jobs that must survive a restart | Workflows | Each step persisted, so a crash resumes rather than restarts | [Async](/a/cloudflare-os-async) |
| Anything on a clock | Cron triggers | 15 minutes of CPU per invocation against 30 seconds for a request | [Async](/a/cloudflare-os-async) |
| Fetching pages a plain HTTP client cannot | Browser | Real headless Chromium inside the account, billed by browser-hour | [Browser](/a/cloudflare-os-browser) |
| Outbound transactional mail | `send_email` binding | No mail vendor and no SMTP credentials in the request path | not published |
| Protecting an admin surface | Cloudflare Access | Identity in front of a route, before the Worker runs | not published |

[[embed:source:s15]]
[[embed:source:s16]]
[[embed:source:s17]]

## Six documented reasons to keep an application off this platform

**Data residency, below Enterprise.** Workers gives no choice of region or country for where code executes; the product that constrains it, Regional Services, is an Enterprise add-on. One developer moved a whole TypeScript Workers project to Django over it: *"I recently ported an entire TS project from cloudflare workers to a django python app since cloudflare workers don't support choice of region/country when deploying workers."* The nuance his complaint misses: Durable Objects **do** take a jurisdiction on every plan — `env.MY_DURABLE_OBJECT.jurisdiction("eu")`, with `eu`, `us` and `fedramp` permitted — and then run and persist only there. State can be pinned; request handling cannot. Both are true, of different layers.

**D1 latency and reliability, over a year in production.** *"Using D1 in production for over an year on multiple projects - I can confirm response times to simple queries regularly take 400ms and beyond. On top there's constant network, connection and a plethora of internal errors."* A second production user, a year later on a different thread: *"D1 reliability has been bad in our experience. We've had queries hanging on their internal network layer for several seconds, sometimes double digits over extended periods (on the order of weeks)."*

**No transaction across two requests.** *"Another fun limitation is that a transaction cannot span multiple D1 requests, so you can't select from the database, execute application logic, and then write to the database in an atomic way."* His workaround: pack a precondition into the first statement of a batch and make it throw on purpose.

**Per-tenant database sharding does not work in practice.** The documented one-database-per-customer pattern collapses on the binding model: *"It's not possible to set up per-user data in D1. Like in theory you probably could, but the DX infrastructure to make it possible is non-existent - you have to explicitly bind each database into your worker."*

**The deployment tool, if you live in a monorepo.** A developer who moved a Cloudflare stack to Azure gave tooling as the reason, not price: *"I found the experience of maintaining and deploying workers to be terrible and so monorepo-unfriendly due to wrangler forced usage for deploys (or at least I haven't found better ways)."*

**Products get superseded underneath you.** Pages, which this request layer runs on, is now something Cloudflare recommends migrating away from — described approvingly: *"This is kind of what happened (is happening) with pages right now. Workers gained pretty much all of their features and are now the recommended way to deliver static sites too."* One developer cannot follow that recommendation at all: *"I had to use Pages since Workers don't support 'Custom domains outside Cloudflare zones'. There's no way I can transfer the domain since I have subdomains tightly integrated with AWS services."*

[[embed:source:s26]]
[[embed:source:s27]]
[[embed:source:s28]]
[[embed:source:s29]]
[[embed:source:s30]]
[[embed:source:s31]]
[[embed:source:s32]]
[[embed:source:s33]]

## Four people who stayed, at four different scales

**Someone who could not code five months earlier shipped the whole stack.** *"Stack: Next.js 14 + React 18 on Cloudflare Pages, Hono 4.10 API on Cloudflare Workers (60+ route modules), 4x Cloudflare D1 databases (~180 tables total), Neon Postgres via Prisma + Hyperdrive, KV + R2 + Durable Objects + Queues."* Four databases and roughly 180 tables is not a toy schema.

**A migration off managed Postgres took a week.** *"In a week, from start to production, I migrated an AWS RDS database to Cloudflare D1 behind an OpenAPI REST interface over HTTPS using itty-router-openapi [1]. This will save me at least $250 pa."*

**Multi-million monthly actives on SQLite inside Durable Objects.** *"We serve multi million MAU on sqlite orchestrated through durable objects. It's not the most complex thing in the world but it goes further than CRUD. It costs us such a small amount of money for what it does."*

**A live multiplayer game, with the cost boundary stated out loud.** Answering a challenge that Durable Objects are too expensive for a game: *"DO alarms handle the time-based stuff (fleet arrivals, combat resolution, resource ticks) so there's no persistent connection cost. so far costs have been negligible."* The caveat is the useful half: that holds because the game is tick-based. Anything realtime wants WebSockets, and the billing changes with it.

[[embed:source:s34]]
[[embed:source:s35]]
[[embed:source:s36]]
[[embed:source:s37]]

## The person who built Workers argues against the default choice

*"It's almost always better to use Durable Objects storage, rather than D1. Even if you only want a single global database, it's better to implement that as a singleton Durable Object, than by using D1."* His reasoning: D1 already **is** a singleton Durable Object wrapping SQLite, so going direct puts the code in the same place as the data instead of one hop away, and read replication is the only thing D1 keeps that a raw object does not have.

He is as blunt in the other direction about KV, to a developer using it as a datastore: *"KV is not a distributed database and is really not intended as a database alternative at all. It's more meant for distributing bits of config globally. Cost aside, writes are way too slow for database-ish use."* That is why the KV namespace in this application holds 19 keys and not 19,000.

Against both sits a production dissent from the same D1 thread — *"I am running 2 production apps on Cloudflare workers, both using D1 for primary storage. I found the performance ok, especially after enabling Smart Placement"* — and an independent measurement from a purpose-built harness landing between the positions: *"North America performance (US and Mexico) had ~200ms+ latency per query, spiking to 500ms or higher in the test application I made using workers and D1."*

Both sides measured something real. D1's convenience and D1's tail latency are both real, and which dominates depends on whether the Worker and the database end up in the same region. Smart Placement is the one lever that decides it. If a 400 ms tail on a simple query is unacceptable, the read path does not belong on D1.

[[embed:source:s38]]
[[embed:source:s39]]
[[embed:source:s40]]

## Symptom, cause, fix

Error strings verbatim, each from a filed report or from a command run against this account.

| Symptom | Cause | Fix |
| --- | --- | --- |
| `too many terms in compound SELECT: SQLITE_ERROR [code: 7500]` | More than about five `UNION ALL` terms in one D1 statement | Split it, or use `d1.batch()` |
| `workers.api.error.script_too_large` on deploy, fine locally | The bundled Pages Functions script exceeds 1 MiB. One report is a 2.4 MB compiled WASM file; another is a blog that crossed 200 posts | Move the asset to R2 and fetch it, or split the route into its own Worker |
| `string or blob too big: SQLITE_TOOBIG` on a Workflow step | A step output above the SQLite value ceiling — reported at ~200 KB as a `Uint8Array`, while the same bytes as an `ArrayBuffer` pass | Write the payload to R2, return the key as the step output |
| Every route returns 500 under `wrangler dev --remote`, production fine | Declaring a queue producer binding breaks all routes in remote dev, including routes that never touch the queue | Develop locally, or comment the producer out while using `--remote` |
| Browser Rendering REST calls return 400, codes 7003 / 7000, token verified | Reported against the REST endpoint with correct account id and permissions | Call the `MYBROWSER` binding from inside a Worker instead of the REST API |
| A Durable Object alarm silently stops firing forever | A failed handler leaves a past timestamp in storage, so every later scheduling call sees an alarm set and skips | Compare `getAlarm()` against `Date.now()`, not against null |
| Pages Function returns 500 with an empty body in production only, no logs | The script fails before it can emit output | Wrap the handler in try/catch, return the error text, deploy again |
| Access-fronted API: `/api/health` works, every scoped call fails | The Access JWT carries an identity that is not a member of the resource requested | Map the service-token identity to a real principal before authorising |
| Forwarded mail silently never arrives | Recipient providers block Cloudflare's forwarding IP ranges: *"most times Outlook just blocks Cloudflare IP ranges and emails never get routed to my Outlook mail box"* | Send from your own authenticated domain instead of relying on forwarding |

[[embed:source:s20]]
[[embed:source:s21]]
[[embed:source:s22]]
[[embed:source:s23]]
[[embed:source:s24]]
[[embed:source:s41]]

## The volume: nine pages, seven of them live

| Page | What it settles | Status |
| --- | --- | --- |
| [D1 as the spine](/a/cloudflare-os-d1) | Why two SQL databases, one append-only | live |
| [KV as the fast lane](/a/cloudflare-os-kv) | What belongs in KV when SQL is already present | live |
| [R2 as the object store](/a/cloudflare-os-r2) | What free egress is worth, and what R2 does not do that S3 does | live |
| [Pages Functions as the request layer](/a/cloudflare-os-functions) | One deployment for every page and route, and its size ceiling | live |
| [Workers and Durable Objects](/a/cloudflare-os-workers) | Jobs that cannot live in a request, and the objects holding state between them | live |
| [Queues, workflows and cron](/a/cloudflare-os-async) | Three different answers to "later", and which one each job needs | live |
| [Browser Rendering as the eyes](/a/cloudflare-os-browser) | Fetching what a plain client cannot, and what a browser-hour costs | live |
| Email | The `send_email` binding, routing rules, and the authentication result that made mail land | not written; `/a/cloudflare-os-email` redirects here |
| Access and secret posture | Which surfaces are public, which need the owner key, the one-token model | not written; `/a/cloudflare-os-access` redirects here |

The capability layer above every binding here — one table of rows, each naming a function, an endpoint, a model or a flow — is documented in [891 tools, zero tool schemas](/a/tooling-as-data), [What a directory row is](/a/directory-row-contract) and [The four-step loop](/a/dispatch-four-step-loop).

## How the counts on this page were taken

Four commands, run from the repository root on 2026-07-26. The account identifier wrangler prints in error output is redacted.

```
# 387 JavaScript files under functions/, 214 of them exporting a request handler,
# 271 exported handler functions in total
find functions -name '*.js' | wc -l
grep -rlE 'export (async )?(function|const) onRequest' functions --include='*.js' | wc -l
grep -rhoE 'export (async )?(function|const) onRequest[A-Za-z]*' functions --include='*.js' | wc -l

# 91 tables in the content spine, 12 in the event store
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT name FROM sqlite_master WHERE type='table'"

# row counts; the meta object in the response carries rows_read and size_after
npx wrangler d1 execute loop-shared-events --remote --json --command \
 "SELECT 'events' t, COUNT(*) n FROM events UNION ALL SELECT 'invocations', COUNT(*) FROM invocations UNION ALL SELECT 'capabilities', COUNT(*) FROM capabilities UNION ALL SELECT 'anchors', COUNT(*) FROM anchors"
# events 400,756 · invocations 159,761 · capabilities 1,026 · anchors 6
# "rows_read": 561549 · "size_after": 1061666816

# three R2 buckets, six KV namespaces on the account
npx wrangler r2 bucket list
npx wrangler kv namespace list
```

The 387 figure matches the count recorded for this project earlier and is unchanged today. Those four `COUNT(*)` terms read 561,549 rows between them — $0.00056 of billable work, and the exact shape of the query that stops being free when the tables are a thousand times larger. `COUNT(*)` scans.


## Sources

1. Cloudflare Workers bindings — https://developers.cloudflare.com/workers/runtime-apis/bindings/
2. Cloudflare Pages Functions bindings — https://developers.cloudflare.com/pages/functions/bindings/
3. Cloudflare service bindings — https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/
4. Wrangler configuration reference — https://developers.cloudflare.com/workers/wrangler/configuration/
5. Cloudflare Workers secrets — https://developers.cloudflare.com/workers/configuration/secrets/
6. Cloudflare Workers pricing — https://developers.cloudflare.com/workers/platform/pricing/
7. Cloudflare D1 pricing — https://developers.cloudflare.com/d1/platform/pricing/
8. Cloudflare Workers KV pricing — https://developers.cloudflare.com/kv/platform/pricing/
9. Cloudflare R2 pricing — https://developers.cloudflare.com/r2/pricing/
10. Cloudflare Durable Objects pricing — https://developers.cloudflare.com/durable-objects/platform/pricing/
11. Cloudflare Queues pricing — https://developers.cloudflare.com/queues/platform/pricing/
12. Cloudflare Workflows pricing — https://developers.cloudflare.com/workflows/reference/pricing/
13. Cloudflare Browser Rendering pricing — https://developers.cloudflare.com/browser-rendering/pricing/
14. Send emails from Workers — https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/
15. Durable Object data location — https://developers.cloudflare.com/durable-objects/reference/data-location/
16. Cloudflare Regional Services — https://developers.cloudflare.com/data-localization/regional-services/
17. Migrate from Pages to Workers — https://developers.cloudflare.com/pages/migrate-to-workers/
18. D1 query API reference — https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/query/
19. KV versus D1 benchmark harness — https://github.com/bruceharrison1984/kv-d1-benchmark
20. Workflows (local `wrangler dev`): a ~200 KB `Uint8Array` step output fails with `string or blob too big: SQLITE_TOOBIG`, but the same bytes as an `ArrayBuffer` (or a 2 MB string) succeed — https://github.com/cloudflare/workers-sdk/issues/14101
21. Deploying on Cloudflare Pages, script_too_large? — https://github.com/nuxt-modules/og-image/issues/193
22. Queue producer binding causes 500 errors on all routes when using `wrangler dev --remote` — https://github.com/cloudflare/workers-sdk/issues/9642
23. Cloudflare Browser Rendering API (Code 7003/7000) Failure in Worker — https://github.com/cloudflare/workers-sdk/issues/10864
24. [BUG] Durable Objects alarm not firing due to stale past alarms remaining in storage — https://github.com/opennextjs/opennextjs-cloudflare/issues/929
25. Durable Object alarm loop: $34k in 8 days, zero users, no platform warning — https://news.ycombinator.com/item?id=47787042
26. Cloudflare recommends migrating from Pages to Workers — https://news.ycombinator.com/item?id=44855519
27. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43607561
28. Cloudflare's AI Platform: an inference layer designed for agents — https://news.ycombinator.com/item?id=47797766
29. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43614249
30. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43610222
31. Lowstorage: JSON-based database for Cloudflare Workers and R2 buckets — https://news.ycombinator.com/item?id=38509302
32. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43646198
33. Cloudflare recommends migrating from Pages to Workers — https://news.ycombinator.com/item?id=44854848
34. 5 months ago I'd never coded anything. I now have full-stack analytics platform — https://news.ycombinator.com/item?id=47146087
35. Without saying "it's scalable", convince me that Serverless is worth it — https://news.ycombinator.com/item?id=38964629
36. SQLite Is All You Need — https://news.ycombinator.com/item?id=48946048
37. Show HN: I rebuilt a 2000s browser strategy game on Cloudflare's edge — https://news.ycombinator.com/item?id=47785298
38. Temporary Cloudflare accounts for AI agents — https://news.ycombinator.com/item?id=48611834
39. OAuth for all — https://news.ycombinator.com/item?id=48672342
40. Journey to Optimize Cloudflare D1 Database Queries — https://news.ycombinator.com/item?id=43608066
41. Cloudflare Email Service: private beta — https://news.ycombinator.com/item?id=45373715
42. Configuration inventory reproduced from the production repository — https://miscsubjects.com/a/cloudflare-os
43. Indexed and scanning D1 query comparison — https://miscsubjects.com/a/cloudflare-os
44. Production resource counts — https://miscsubjects.com/a/cloudflare-os


---

# Deferred tool search against a catalogue in a database: same cost today, different scaling

slug: tool-search-vs-catalogue-as-data · https://miscsubjects.com/a/tool-search-vs-catalogue-as-data · tags: tooling, mcp, architecture, comparison, tool-search, context-cost, measurement · updated 2026-07-26T03:53:50.309Z

On a catalogue of 891 capabilities, the two designs cost the same. Deferring the schemas and searching them on demand: 14,109 input tokens on a measured turn. Keeping the catalogue in a database behind one HTTP endpoint and sending no catalogue at all: 14,071. A difference of 38 tokens, which is noise.

The number is not the finding. The finding is what sits inside each number:

- With deferred tool search, **catalogue size is a term in the cost equation** — in Claude Code, one line per capability name in every request — and the mechanism only exists if the host implements it.
- With the catalogue in a database, catalogue size is not a term at all, and the mechanism is an HTTP request, which every client already has.

That holds at 50 capabilities and at 5,000. The token count expires the next time either side ships a change; the structural statement does not.

**What would falsify it.** One thing, precisely: a host that defers with *no per-capability residue in context*. Anthropic's server-side variant is documented to be exactly that — "the API excludes deferred tools from the system-prompt prefix" — so on the Claude API directly, tool search is also flat in context and the claim above is false as stated. It holds for Claude Code's client-side implementation, which is the one measured here, and Anthropic's own Claude Code documentation says so: "Only tool names and server instructions load at session start." Two implementations of one feature, two different cost curves. Check which one you are running before you believe either number.

[[embed:source:s1]]

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Every term used here, defined once

| Term | What it means on this page |
| --- | --- |
| **Tool definition** | A name, a description and a JSON Schema for the arguments, sent to the model so it can call something. Definitions are input tokens and are re-sent on every turn. |
| **Turn** | One request to the model and its reply. The whole conversation, including all tool definitions, is re-sent each time. |
| **MCP** | Model Context Protocol. A standard for a server to publish tool definitions and for a client to fetch them and put them in the prompt. |
| **Deferred loading** | Sending a tool's definition in the request but keeping it out of the model's context until it is asked for. Set with `defer_loading: true` on the API, or `ENABLE_TOOL_SEARCH=true` in Claude Code. |
| **Tool search** | The search tool the model calls to pull a deferred definition into context. Two vendor variants: regex (`tool_search_tool_regex_20251119`) and BM25 (`tool_search_tool_bm25_20251119`). |
| **Catalogue-as-data** | One SQL row per capability, holding the call shape, the docs, the argument template and the name of the credential. The model gets no list; it resolves an intent to a key over HTTP. [The full row contract](/a/directory-row-contract). |
| **Round trip** | One network request and response. Distinct from a model turn: four round trips can happen inside one turn, or across four. |
| **Prompt cache** | The provider storing the unchanging front of your prompt so re-sending it is cheaper. Anything that changes early in the prompt invalidates everything after it. |

## What the client actually puts on the wire, both ways

Both figures below come from one machine, `claude-cli 2.1.165`, same prompt, same working directory, one MCP server attached, captured by a local server that logs the request body and answers with a canned reply. The script is at the end of this page.

**With `ENABLE_TOOL_SEARCH=true`** the request carries nine tool definitions totalling 38,116 bytes of JSON:

```text
Agent            4351 bytes
AskUserQuestion  4199
Bash             2661
Edit              964
Read             1636
Skill            1713
ToolSearch       1440
Workflow        20503
Write             639
                -----
                38116 bytes, 9 definitions
```

Everything else arrives as a bare list of names in a `system` role message, which opens: "The following deferred tools are now available via ToolSearch. Their schemas are NOT loaded — calling them directly will fail with InputValidationError."

**With `ENABLE_TOOL_SEARCH=false`**, same session, the same request carries 858 definitions and 522,746 bytes — 13.7 times the JSON, for the identical set of capabilities.

**With the catalogue behind HTTP** the client sends its nine built-in definitions and nothing else. No MCP server attached, no name list, and all 891 capabilities still reachable, because reaching them is a `GET` and a `POST` to one endpoint rather than a tool the model was handed.

The translator carrying these requests to a non-Anthropic model converts the Anthropic `tools` array into OpenAI `function` entries one for one — `toOpenAITools()`, `functions/api/aig/[[path]].js:213-231` — and sizes an incoming request at lines 448-456 with `Math.ceil(chars / 3.7)`. The capture server behind the byte counts is `tools/capture-gateway.mjs:1-55` in the public repository, logging `n_tools` and `tool_names` at lines 25-26.

[[embed:source:s25]]

## The ToolSearch definition itself costs 1,440 bytes, once

Measured, not estimated. It is 3.8% of the nine-definition payload and it does not grow with the catalogue:

```json
{ "name": "ToolSearch",
  "description": "Fetches full schema definitions for deferred tools so they can be called. …",
  "input_schema": { "type": "object",
    "properties": { "query": {"type":"string"}, "max_results": {"type":"number","default":5} },
    "required": ["query","max_results"] } }
```

The catalogue-as-data equivalent — the instruction telling a model to resolve before it invokes — is prose in the system prompt, not a definition. One HN commenter who hand-rolled the same gate before either vendor shipped one put his at "about 60 tokens". Both are small. Neither side wins this row.

[[embed:source:s26]]
[[embed:source:s19]]

## Dimension by dimension, with a verdict in every cell

| Dimension | Deferred tool search | Catalogue-as-data | Verdict |
| --- | --- | --- | --- |
| Input tokens per turn, 891 capabilities | 14,109 | 14,071 | **Tie.** 38 tokens apart. |
| How cost scales with catalogue size | Client-side: ~37 bytes of name per capability, every turn. Server-side: nothing in the prefix, but every full definition is uploaded on every request. | Nothing. The 892nd row changes no byte the model sees. | **Catalogue-as-data**, and only structurally — at 891 rows the difference is invisible. |
| Prompt-cache behaviour | Documented as preserved server-side: deferred tools are excluded from the cached prefix and expanded inline. Reported as broken by operators lazy-loading definitions themselves. | Nothing about the tool surface ever changes, so nothing invalidates. | **Catalogue-as-data**, narrowly. The vendor claim and the field reports are both quoted below and describe different implementations. |
| Host support required | Yes. A host implementing deferral and expanding `tool_reference` blocks, on a model that supports them: Sonnet 4.5, Haiku 4.5, Opus 4.5 and later. | None. An HTTP client. | **Catalogue-as-data.** Not close. |
| Discovery latency and round trips | Zero network round trips — the search is answered locally or inside the same API call — but one extra model turn before the work starts. | Four HTTP round trips, measured at 2.185 s total for resolve → contract → invoke → receipt. | **Tool search.** The four round trips are real seconds. |
| Accuracy of tool selection | Vendor states selection degrades past 30-50 tools and that search keeps it high. The counter-argument: this is retrieval, which the field abandoned for full definitions on accuracy grounds. | A ranked query you own, index and can test offline. Measured here: `?ask=what time is it` returned 12 candidates and recommended `NOW` — not `TIME_NOW`, which was also in the list. | **Unresolved.** Both are retrieval. Neither side has published a head-to-head. |
| Adding a new capability | Publish it from a server; clients pick it up on reconnect, which invalidates the cache when tools sit in the prefix. | One `INSERT`. Live immediately, no restart, no reconnect. | **Catalogue-as-data.** |
| Who can call it | A harness that implements deferral. | Any HTTP client, including a model with no tool-calling support at all. | **Catalogue-as-data.** |
| Argument validation | Real JSON Schema, enforced by the host, with strict mode composing on top of deferral. | Pipe-delimited string, optional `input_schema` on the row, validated by the endpoint after the call is made. | **Tool search.** Decisively. |
| Ecosystem | Thousands of published MCP servers work unchanged. | Each server must become rows, or be reached one `http` row per endpoint. | **Tool search.** Decisively. |
| Observability and receipts | Not part of the mechanism. Tool lists are client-side state. | Every invocation writes an addressable receipt with input and output hashes, replay and repair links. | **Catalogue-as-data.** |
| Failure mode | A deferred tool is never searched for, and the run completes as if it did not exist. Filed against two harnesses. | A key does not resolve and the step fails with a `did_you_mean` list. | **Catalogue-as-data**, if a loud failure is worth more to you than a quiet one. |
| Effort to adopt inside a Claude host | One environment variable. | Implement or adopt a four-step protocol. | **Tool search.** Not close. |

[[embed:source:s5]]
[[embed:source:s16]]

## What tool search has that a catalogue behind HTTP does not

**No service to run.** Deferral is a setting — no database, no endpoint, no uptime, no deployment.

**It works when your infrastructure does not.** A catalogue behind HTTP is a hard dependency: endpoint down, model has no capabilities. Deferred definitions travel inside the request.

**Typed arguments.** Definitions carry real JSON Schema and the host constrains the model's output to match it. A row taking `"arg1|arg2"` gets validation only after the call has been made.

**It is a standard other harnesses are adopting.** `defer_loading` is not Anthropic-only — the Codex bug report below is evidence it shipped there too. A protocol one build invented is not a standard, whatever its merits.

[[embed:source:s2]]
[[embed:source:s15]]

## Six ways deferred tool search is documented to break

Every row is a filed report or a vendor page, quoted.

| What goes wrong | Where it is documented | What it costs you |
| --- | --- | --- |
| First-party servers are exempt from deferral, with no opt-out | anthropics/claude-code#76372: "With tool search active (`ENABLE_TOOL_SEARCH` unset), third-party MCP tools correctly defer to names-only. But three Desktop built-in servers load complete schemas upfront every session" | ~3.9k tokens per session that no setting removes |
| Servers visible in the client are invisible to the search index | anthropics/claude-code#57033: "the `ToolSearch` deferred-tool discovery mechanism does NOT include them in its index. Any `ToolSearch` query that should match a claude.ai MCP tool returns zero results" | Connectors show as Connected and cannot be reached |
| The deferral threshold is computed from the wrong model | NousResearch/hermes-agent#57520: "For any session running a model *other than* the configured default … the gate is scaled to the wrong window." | A 98,304-token local model gets a threshold sized for a 256K cloud model |
| A run completes silently empty | openai/codex#24536: "`codex exec` can silently finish with no assistant message when an explicitly configured MCP tool is deferred behind `tool_search`." | The task looks done and nothing happened |
| Lazy loading invalidates the prompt cache | sophiabits on HN: "The main problem with this approach at the moment is it busts your prompt cache, because LLMs expect all tool definitions to be defined at the beginning of the context window." | Input tokens are the main cost driver, so the saving can invert |
| It does not fully solve the problem it is cited for | cheema33 on HN: "And no, the tool search function recently introduced by Anthropic does not completely solve this problem." | You still pay for servers you never invoke on that turn |

[[embed:source:s8]]
[[embed:source:s9]]
[[embed:source:s10]]
[[embed:source:s11]]
[[embed:source:s21]]
[[embed:source:s20]]

And the objection that is not a bug, which is the strongest thing said against the whole direction:

> Seems like we traded scalability for accuracy, then accuracy for scalability… but I guess maybe we've come out on top because whatever they are using for tool search is better than RAG?

That is morelandjs on Hacker News, 2025-11-24, under Anthropic's own advanced-tool-use announcement. The field moved to putting every definition in context *because retrieval was less accurate*. Tool search walks that back and asks you to trust a BM25 or regex index instead. Anthropic's position is the opposite — selection "degrades once you exceed 30–50 available tools", and search keeps it high. Both statements are published, neither is backed by a head-to-head benchmark either side has released, and a catalogue-as-data resolver is retrieval too, so it inherits the same objection. This page does not resolve it and will not pretend to.

[[embed:source:s17]]

## Five ways catalogue-as-data breaks

**No host-side type validation.** The model sends a pipe-delimited string. Nothing constrains its output to a schema before the call leaves. Malformed arguments are caught by the endpoint, after the request, and surface as a failed invocation rather than a rejected tool call.

**No standard.** One build's protocol. No ecosystem, no published servers, no client that already speaks it.

**It needs a live service.** The catalogue's uptime is now the agent's capability uptime, which deferred definitions never are.

**Four round trips before any work happens.** Measured on 2026-07-25 against production:

```text
resolve   GET  /api/dispatch?ask=what%20time%20is%20it     200   0.619 s   12,332 B
contract  GET  /api/dispatch?key=TIME_NOW&format=markdown  200   0.215 s    4,421 B
invoke    POST /api/dispatch {"key":"TIME_NOW","body":""}  200   1.047 s   15,607 B
receipt   GET  /api/dispatch?confirm=inv_3wt7dcbp2c        200   0.304 s   15,049 B
                                                          -----------------------
                                                           2.185 s   47,409 B
```

Deferred tool search pays none of that in network time. It pays one extra model turn instead, which on a slow model is worse and on a fast one is better.

**The resolve step is a search that can miss.** In the run above, `?ask=what time is it` returned 12 matches and recommended `NOW`. `TIME_NOW` was in the list and was not the recommendation. Both work. But that is the accuracy objection landing on this side of the table, in a measurement taken for this page, and it is the same failure class the critics aim at tool search.

[[embed:source:s27]]

## The arithmetic at 50, 891 and 5,000 capabilities

Two measured constants do the work, both from the captures above:

- **571 bytes** per tool definition. Derived: (522,746 − 38,116) bytes ÷ (858 − 9) definitions.
- **36.7 bytes** per deferred tool name. Derived: 30,551 bytes of names ÷ 833 MCP tool names.

Bytes are exact. To convert to tokens: (149,187 − 14,109) input tokens across (856 − 9) definitions gives **159 tokens per definition**, and dividing the byte figure by the token figure gives **3.59 bytes per token** for this JSON. Independently, the translator in this build estimates at `chars / 3.7`. The two agree closely enough to use for orders of magnitude and not closely enough to quote to four figures.

| Catalogue size | Every definition in context | Tool search, client-side (names only) | Catalogue-as-data |
| --- | --- | --- | --- |
| 50 | 50 × 571 = 28,550 B ≈ 7,950 tokens | 50 × 36.7 = 1,835 B ≈ 511 tokens | 0 B, 0 tokens |
| 891 | 891 × 571 = 508,761 B ≈ 141,700 tokens | 891 × 36.7 = 32,700 B ≈ 9,110 tokens | 0 B, 0 tokens |
| 5,000 | 5,000 × 571 = 2,855,000 B ≈ 795,300 tokens | 5,000 × 36.7 = 183,500 B ≈ 51,100 tokens | 0 B, 0 tokens |

At **50 capabilities** all three are rounding errors against a 200,000-token window. Anthropic says as much: standard tool calling "is a better fit when you have fewer than 10 tools, every tool is used in every request, or your tool definitions are small". Do not build a catalogue for 50 rows.

At **891**, the measured case, column one is a bill you notice and the other two are small. That is why the two measured turns landed 38 tokens apart. The 9,110 tokens of names in column two are real, hiding inside a number that also holds the conversation.

At **5,000**, column one does not fit in a 200,000-token window at all, column two costs 51,100 tokens per turn in names before anything is retrieved, and column three still costs nothing. That is where the structural difference becomes a number instead of an argument — and where the server-side variant, which keeps names out of the prefix, would collapse column two to roughly zero, subject to its documented ceiling of 10,000 deferred tools per request.

Add to column two, in every row, the definitions actually retrieved: 159 tokens each, up to five per search by default.

[[embed:source:s14]]
[[embed:source:s24]]

## A non-Claude model drove the deferred loop, against two documentation pages

Two vendor pages say this configuration does not work.

Anthropic's Claude Code prompt-caching page, on when tool definitions land in the cached prefix instead of being deferred: this happens "when tool search is unavailable or disabled … such as on Google Cloud's Agent Platform or with a custom `ANTHROPIC_BASE_URL` gateway". The Claude Code MCP page is more explicit about the mechanism: tool search "is also disabled when `ANTHROPIC_BASE_URL` points to a non-first-party host, since most proxies don't forward `tool_reference` blocks."

Moonshot's Claude Code guide, in its environment-variable table for `ENABLE_TOOL_SEARCH`: "The Kimi endpoint does not support this feature yet; it must be set to `false`, otherwise tool calls misbehave."

**Measured on 2026-07-25**, `claude-cli 2.1.165`, `ANTHROPIC_BASE_URL` pointed at a self-hosted translator in front of Cloudflare AI Gateway, model `@cf/moonshotai/kimi-k2.7-code`, `ENABLE_TOOL_SEARCH=true`: Kimi K2.7 Code was asked to use an MCP tool it had never been shown, named `TIME_NOW`. It called `ToolSearch`, received the schema, invoked the tool, and returned the payload:

```json
{"now":"2026-07-25T18:35:56-07:00","today":"2026-07-25","time":"18:35:56",
 "zone":"America/Los_Angeles","iso":"2026-07-25T18:35:56-07:00"}
```

The same session measured 14,109 input tokens against 149,187 with the setting off.

The likeliest reconciliation, stated as an inference and not a fact: Claude Code's tool search is client-side — the client decides which definitions to send and answers `ToolSearch` itself, so it asks nothing of the endpoint. The vendor pages describe the *server-side* variant, which does require the endpoint to expand `tool_reference` blocks. Anthropic's documentation confirms both exist: "Tool search runs as a server-side tool, but you can also implement your own client-side tool search."

Do not settle this by trusting any of the three statements. Point `ANTHROPIC_BASE_URL` at the capture server below and read your own request bodies.

[[embed:source:s3]]
[[embed:source:s4]]
[[embed:source:s28]]

## Given your situation, pick this

| If this is you | Pick | Because |
| --- | --- | --- |
| Under 50 tools, all used most turns | Neither | Definitions in context are cheaper than any machinery around them, and Anthropic says so in its own "when to use" list. |
| Inside Claude Code, MCP servers you did not write, a bill you noticed | Tool search | One environment variable, 10.6× fewer input tokens on the case measured here, nothing to run. |
| Several different models must reach the same capabilities, some without tool calling | Catalogue-as-data | Deferral needs a supporting host and a supporting model. HTTP needs neither. |
| Thousands of capabilities and a fixed context budget | Catalogue-as-data, or the server-side API variant | Client-side deferral still carries a name per capability per turn; the other two carry nothing. |
| Every call must produce an auditable receipt | Catalogue-as-data | Receipts are not part of the deferral mechanism at any layer. |
| Typed arguments matter more than portability | Tool search | Real JSON Schema, host-enforced, composing with strict mode. |
| You are on a proxy or a non-Anthropic model | Measure before choosing | Two vendor pages say it will not work; one measurement says it does. Yours is the only one that decides. |
| You want both | Both | The same table here is projected as a per-row MCP tool list for hosts that want tools, and as the protocol for everything else: [MCP as a projection, not a home](/a/mcp-as-a-projection). |

[[embed:source:s22]]
[[embed:source:s23]]
[[embed:source:s13]]
[[embed:source:s12]]

## Reproduce every number on this page

**The wire captures.** Save this as `tooldump.mjs`. It answers the Anthropic Messages API with a canned reply and logs the byte size of every tool definition the client sends.

```javascript
import http from 'node:http';
import fs from 'node:fs';
const OUT = '/tmp/tooldump.jsonl';
http.createServer((req, res) => {
  let raw = '';
  req.on('data', c => (raw += c));
  req.on('end', () => {
    let b = null; try { b = JSON.parse(raw); } catch {}
    if (b && Array.isArray(b.tools)) {
      fs.appendFileSync(OUT, JSON.stringify({
        model: b.model, n_tools: b.tools.length,
        tools_bytes: JSON.stringify(b.tools).length,
        per_tool: b.tools.map(t => ({ name: t.name, bytes: JSON.stringify(t).length })),
      }) + '\n');
    }
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ id:'msg_1', type:'message', role:'assistant',
      model: b?.model || 'local', content:[{type:'text',text:'ok'}],
      stop_reason:'end_turn', usage:{input_tokens:10,output_tokens:2} }));
  });
}).listen(8788, () => console.log('tooldump on :8788'));
```

Run it, then run the client once each way:

```bash
node tooldump.mjs &
for v in true false; do
  ANTHROPIC_BASE_URL=http://localhost:8788 ANTHROPIC_AUTH_TOKEN=x \
  ANTHROPIC_MODEL=local ENABLE_TOOL_SEARCH=$v claude -p "say ok" >/dev/null
done
python3 -c "
import json
for l in open('/tmp/tooldump.jsonl'):
    r = json.loads(l)
    print(r['n_tools'], r['tools_bytes'])"
```

Expected output on this machine, `claude-cli 2.1.165`, one MCP server attached:

```text
9 38116
858 522746
```

The name-list figure comes from the same file: filter `per_tool` to names beginning `mcp__` and sum their lengths. Expected `833` names and `30551` bytes.

**The four-step timing.** Every URL is public; only the invoke needs a credential:

```bash
curl -s -o /dev/null -w "%{http_code} %{time_total}s %{size_download}B\n" \
  "https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it"
curl -s -o /dev/null -w "%{http_code} %{time_total}s %{size_download}B\n" \
  "https://miscsubjects.com/api/dispatch?key=TIME_NOW&format=markdown"
```

The receipt for the invocation timed above is public and keyless: [`inv_3wt7dcbp2c`](https://miscsubjects.com/receipt/inv_3wt7dcbp2c).

[[embed:source:s7]]
[[embed:source:s18]]

**The gateway rows.** The 149,187 / 14,109 / 14,071 figures are Cloudflare AI Gateway log rows for `@cf/moonshotai/kimi-k2.7-code` on 2026-07-25; the method for producing them is [Why MCP tool schemas are most of your bill](/a/mcp-tool-search-cost). The design on the other side of this comparison is [Tooling as data](/a/tooling-as-data).

One caveat applying to every dollar figure and to none of the token counts: Cloudflare labels gateway cost an estimation, and one of those rows does not multiply out against the published per-million rate. The argument here is built on token counts for that reason.


## Sources

1. Tool search tool — Claude API documentation — https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
2. Claude Code — Scale with MCP tool search — https://code.claude.com/docs/en/mcp
3. Claude Code — what invalidates the prompt cache — https://code.claude.com/docs/en/prompt-caching
4. Moonshot — Use Kimi with Claude Code — https://platform.kimi.ai/docs/guide/claude-code-kimi
5. Model Context Protocol specification — Tools — https://modelcontextprotocol.io/specification/2025-06-18/server/tools
6. Tool use with prompt caching — defer_loading and cache preservation — https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-use-with-prompt-caching
7. claude-code-cloudflare-gateway — the translator and the capture harness — https://github.com/redacted/claude-code-cloudflare-gateway
8. Desktop built-in MCP tool schemas load non-deferred with no opt-out — https://github.com/anthropics/claude-code/issues/76372
9. ToolSearch does not index tools from claude.ai-hosted MCP servers — https://github.com/anthropics/claude-code/issues/57033
10. codex exec can silently complete empty when configured MCP tools are deferred — https://github.com/openai/codex/issues/24536
11. tool_search auto-gate computes its threshold from the wrong model — https://github.com/NousResearch/hermes-agent/issues/57520
12. Dynamic MCP tool loading to reduce context window consumption — https://github.com/github/app/issues/1398
13. Context bloat: 16.9k tokens on MCP tool definitions loaded into the main agent — https://github.com/yonatangross/orchestkit/issues/885
14. Apideck CLI — an AI-agent interface with much lower context consumption than MCP — https://news.ycombinator.com/item?id=47400262
15. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48332411
16. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48332962
17. Comment on: Claude Advanced Tool Use — https://news.ycombinator.com/item?id=46039648
18. Comment on: I still prefer MCP over skills — https://news.ycombinator.com/item?id=47719499
19. Comment on: I still prefer MCP over skills — https://news.ycombinator.com/item?id=47719249
20. Comment on: Chrome DevTools MCP — https://news.ycombinator.com/item?id=47392361
21. Comment on: When does MCP make sense vs CLI? — https://news.ycombinator.com/item?id=47209810
22. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48336021
23. Comment on: MCP is dead; long live MCP — https://news.ycombinator.com/item?id=47381322
24. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48331540
25. First-party wire capture: 9 definitions / 38,116 bytes against 858 / 522,746 bytes — https://github.com/redacted/claude-code-cloudflare-gateway
26. First-party measurement: the ToolSearch definition is 1,440 bytes, and 833 deferred names are 30,551 — https://github.com/redacted/claude-code-cloudflare-gateway
27. First-party timing: four round trips, 2.185 s, 47,409 bytes, with the public receipt — https://miscsubjects.com/receipt/inv_3wt7dcbp2c
28. Gateway log rows: 149,187 / 14,109 / 14,071 input tokens on the same catalogue — https://miscsubjects.com/api/articles/mcp-tool-search-cost


---

# MCP is one view of a capability table, and the view you pick costs 6.25x

slug: mcp-as-a-projection · https://miscsubjects.com/a/mcp-as-a-projection · tags: tooling, mcp, architecture, json-rpc, tool-cost, protocol · updated 2026-07-26T03:53:21.653Z

The Model Context Protocol (MCP) is a way of *describing* a set of capabilities to a model. It is not the place those capabilities live, and it is not the thing that runs them. On this site the capabilities live in one database table — one row per capability — and MCP is one of three ways that table is shown to a model.

**Scope note:** this page measures one thing well — what it costs to show *this* catalogue to a model three different ways. It is not a claim that the catalogue is the architecture. [892 rows, 8 of them MCP](/a/the-directory-is-not-the-object-system) breaks the same table down by runner and category and shows MCP is 8 of the 891 rows measured here, not the subject the table exists to serve.

A **projection** here means exactly one thing: a view generated from a table, holding nothing of its own. Change the table and every view changes in the same instant. Delete a view and nothing is lost. Below, the plainer word **surface** is used wherever it reads more clearly; they mean the same thing.

The three surfaces reach the same capability catalogue. The recorded benchmark puts 6.25× between the cheapest and the dearest turn.

[[embed:source:m1]]

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## MCP at the wire is JSON-RPC 2.0, one of two transports, and a handful of method names

A reader who has never opened the specification can hold the whole thing in one paragraph. A client and a server exchange JSON-RPC 2.0 messages. JSON-RPC is a remote-procedure-call format: a JSON object carrying `jsonrpc`, `method`, `params` and `id`, answered by an object carrying the same `id` and either `result` or `error`. The specification is blunt about this: *"MCP uses JSON-RPC to encode messages. JSON-RPC messages **MUST** be UTF-8 encoded."*

Those messages travel over one of two transports. Standard input and output, for a server running as a local subprocess. Or HTTP POST, for a server running somewhere else — the specification calls the second one Streamable HTTP and requires that *"Every JSON-RPC message sent from the client **MUST** be a new HTTP POST request to the MCP endpoint."* It also states a preference: *"Clients **SHOULD** support stdio whenever possible."*

The method names that matter for a capability catalogue are four:

| Method | Direction | What it carries | What this site's server returns |
| --- | --- | --- | --- |
| `initialize` | client → server | protocol version, client capabilities | `protocolVersion: "2025-06-18"`, `capabilities: {"tools":{"listChanged":false}}`, `serverInfo` |
| `tools/list` | client → server | nothing, or a pagination cursor | an array of `{name, description, inputSchema}` — 831 of them, measured below |
| `tools/call` | client → server | `params.name`, `params.arguments` | `{content:[{type:"text",text:"…"}], isError:false}` |
| `notifications/tools/list_changed` | server → client | nothing | never sent — this server declares `listChanged: false` |

A tool definition is three fields: a name, a description in prose, and a JSON Schema for the arguments. That triple is what lands in the model's context window. Everything expensive about MCP follows from the size of that triple multiplied by the number of tools.

### What MCP is not

- **Not a wire protocol.** The layering claim gets used to wave away responsibility, and it is wrong. JSON-RPC is the wire protocol; MCP sits above it.
- **Not a place capabilities live.** A server holds handlers. Nothing in the specification says where the list of capabilities is stored, or that it must be a hand-written list at all.
- **Not a rule about context loading.** The tools specification says implementations *"are free to expose tools through any interface pattern that suits their needs—the protocol itself does not mandate any specific user interaction model."* Every token figure quoted below is a property of a client, not of the protocol.
- **Not a replacement for an API.** It is a second face on one. The endpoint stays; MCP is a decorated index in front of it.

## One recorded 891-row catalogue cost 6.25× more through its largest surface

The table holds 891 rows — 876 objects answered the live registry today, and the drift is explained under the measurements. Each row is a full capability contract: key, runner, target, documentation, input schema, authority flags. [What a directory row is](/a/directory-row-contract) covers the row itself; [891 tools, zero tool schemas](/a/tooling-as-data) covers why a catalogue is better held as data than as code.

| Surface | Tool definitions in the model's context | What the model can reach | What the client must support | Measured input tokens per turn | Measured cost per turn | What breaks |
| --- | --- | --- | --- | --- | --- | --- |
| **Protocol only** — `POST /api/dispatch` | 0. The 9 definitions present are the client's own built-ins | all 891 rows | an HTTP client. No MCP, no tool calling at all | 14,071 | $0.00456265 | the model must be told four endpoints once; no client UI enumerates the catalogue |
| **Small surface** — `workers/mcp-server` | 7 tools, whatever the catalogue size | all 891 rows, through the `dispatch` tool | an MCP client | not measured | not measured | a client that lists tools shows seven generic entries; discovery becomes a call, not context |
| **Per-row MCP** — `POST /api/mcp` | 831 measured today, 856 recorded | 831 rows, each a named tool | an MCP client | 149,187 | $0.02852109 | prompt cost, tool-selection accuracy, and strict-schema clients rejecting the whole list |
| **Per-row MCP, tool search on** | 9 plus a `ToolSearch` tool | all 891, loaded on demand | a client with deferred tool loading | 14,109 | $0.00443075 | the deferred index does not cover every server — two filed bugs below |

The arithmetic between the first and third rows: 149,187 − 14,071 = **135,116 input tokens burned per turn** holding definitions the turn mostly does not use. 149,187 ÷ 14,071 = **10.6× the input tokens**. $0.02852109 ÷ $0.00456265 = **6.25× the money**. Across a thousand turns that gap is **$23.96**. The token ratio and the money ratio differ because output tokens are in both bills and the cache behaves differently; both are the recorded gateway figures, not a rate derived from one of them.

[[embed:source:m2]]

## Each surface is a file, and the file states its own tool count

**Per-row MCP** is `functions/api/mcp.js`, 189 lines. Lines 118–137 are the entire projection:

```js
const r = await env.DB.prepare(
  'SELECT key, type, category, content, input_schema FROM directory ' +
  'WHERE IFNULL(enabled,1)=1 AND IFNULL(planner_visible,1)=1 ' +
  'ORDER BY IFNULL(planner_rank,100), key'
).all();
```

One row becomes one tool: `name` is the row key, `description` is the first documentation line plus `[type · category]`, `inputSchema` is the row's schema after normalisation. Line 162 is the whole `tools/list` handler. Lines 80–116 exist for one reason worth stating plainly: Moonshot and Kimi clients validate every schema strictly and reject the **entire** tool list on the first violation, so one legacy row with a `required` name missing from `properties` would blank the catalogue for that client. Claude Code tolerates both, which is how the bad schemas accumulated unnoticed.

**Small surface** is `workers/mcp-server/src/index.ts`, 138 lines, a Cloudflare `McpAgent` on a Durable Object. It registers seven tools, lines 19–105:

```
$ grep -A1 'this.server.tool($' workers/mcp-server/src/index.ts | grep '"'
      "d1_query",
      "kv_get",
      "kv_put",
      "list_directory",
      "dispatch",
      "oip_registry",
      "oip_invocations",
```

Seven, not six. An earlier record of this surface counted six by folding `kv_get` and `kv_put` into one line and omitting `d1_query`. The file is the authority and the file says seven. The invariant is the point either way: **seven tools whether the table holds 9 rows or 9,000**, because `dispatch` takes `{key, body}` and the key space is the table. All seven parameter descriptions together come to 429 characters of source.

**Protocol only** is `functions/api/dispatch.js`. No tool definitions are published anywhere. A model that can make an HTTP request resolves an intent, reads a contract, invokes it, and reads a receipt — four calls, set out in [Resolve, read, invoke, receipt](/a/dispatch-four-step-loop).

## Measured today: 831 definitions occupied 434,636 bytes on the wire

Two first-party measurements, both rerunnable. `MCP_TOKEN` is the server's bearer token; `/api/mcp` accepts it in `Authorization` or in `x-mcp-token`.

**Measurement 1 — count the per-row surface.**

```bash
curl -s -X POST https://miscsubjects.com/api/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -o tl.json \
  -w "http=%{http_code} bytes=%{size_download} time=%{time_total}s\n"

python3 -c "import json;t=json.load(open('tl.json'))['result']['tools'];print(len(t), len(json.dumps(t)))"
```

Three readings on 2026-07-26:

| Reading (UTC) | Tools | JSON bytes | What was counted | Wall time |
| --- | --- | --- | --- | --- |
| 04:37:06Z | 833 | 452,157 | Python re-serialization with spaces | not recorded |
| 04:39:08Z | 831 | 451,197 | Python re-serialization with spaces | 0.60 s |
| 05:03:04Z | 831 | 434,636 | exact HTTP response body | 0.267 s |

The HTTP body contains 434,592 bytes of compact tool definitions plus the 44-byte JSON-RPC wrapper. Python's default `json.dumps` adds spaces after separators, which is why the command above reports 451,197 for the same 831 definitions. Both numbers are reproducible; **434,636 is the network payload**.

Where the 451,197-character Python serialization goes, computed from the same file:

| Component of the tool list | Characters | Share |
| --- | --- | --- |
| Descriptions — first doc line plus `[type · category]` | 200,757 | 44.5% |
| Input schemas, after normalisation | 189,354 | 42.0% |
| Tool names | 13,862 | 3.1% |
| JSON structure and quoting | 47,224 | 10.5% |

Compact average per tool: **523 bytes**. Largest single definition in the spaced Python serialization: `CF_OBSERVABILITY_QUERY_WORKER_OBSERVABILITY` at 6,511 bytes. By runner type the 831 split into 462 `fn`, 301 `http`, 50 `flow`, 18 `agent`.

Three counts for the same server now exist on this page — 856 recorded, 833 at 04:37, 831 at 04:39 — and each is correct for its moment. Rows get added, disabled (`enabled = 0`) and hidden (`planner_visible = 0`) while the site runs, and only enabled plus planner-visible rows are projected. A tool count taken from an MCP server is a reading, not a constant. The 891 total and the 856 projection were taken on 2026-07-25.

**Measurement 2 — show that zero definitions still reaches everything.**

```bash
curl -s "https://miscsubjects.com/api/dispatch?registry=1" -o reg.json \
  -w "http=%{http_code} bytes=%{size_download} time=%{time_total}s\n"
python3 -c "import json;print(json.load(open('reg.json'))['count'])"

curl -s "https://miscsubjects.com/api/dispatch?ask=send%20an%20email" | head -c 400
```

Result at 05:03:04Z: `http=200 bytes=1606794 time=0.397555s`, and `876` objects. The resolver call returned `{"protocol":"OIP","version":"1.2.0","kind":"ask","question":"send an email","count":12,"best":{"key":"EMAIL_SEND", …}}` in 14,481 bytes — twelve candidate capabilities ranked, one recommended, none of it resident in a prompt. Nothing was registered with a model. No tool definition was loaded. All 876 objects are invokable by key.

[[embed:source:m3]]

## The case for shipping an MCP surface, at its strongest

The deflationary reading — MCP is a REST API with extra steps — is usually stated by people who then keep using it. **CharlieDigital**, whose team built one, put the deflation and the recommendation in a single sentence: *"MCP is effectively \"just another HTTP REST API\"; OAuth and everything. The key parts of the protocol is the communication shape and sequence with the client, which most SDKs abstract for you"*. Deploying one is no harder than deploying the API underneath it, because the SDK writes the transport.

**brookst** gave the sharpest rebuttal of the just-HTTP framing: *"It's like saying APIs are dead because you can just use HTTP. They're not the same thing, though of course you can hand-roll the higher layer in the lower one. It's just more work, less standard, less valuable."* A decorated index of features beats a bare endpoint list, and the decoration is the product.

**menix** argues the contract itself is the value rather than a nicety. Input and output schemas let a code-writing agent plan one precise program instead of a print-and-inspect loop, and *"Tool results from programmatic calls are not added to Claude's context window, only the final code output is. They report up to 98.7% token savings in some workflows."*

**827a** supplies the dissent every measurement below has to survive: *"The idea that MCP tool definitions take up a certain number of tokens is laughable. That's an implementation detail of the agent harness."* This is correct as written, and the specification agrees — nothing in MCP says a client must paste all definitions into a prompt. The measurements are of clients. They still decide the bill, because those are the clients that exist.

There is an audience argument too. **oortcrate_1** prefers a bash wrapper personally and still grants the point: MCP serves non-technical teammates who want a connection that works without filing a pull request.

## The case against, with the numbers the complainants actually measured

| Who | What they measured | Verdict |
| --- | --- | --- |
| moltar | *"Right now loading GitHub MCP takes something like 50k tokens."* | reduces MCP to "an API with docs"; wants progressive reveal |
| gertjandewilde (Apideck) | *"tool definitions alone burned 50,000+ tokens before the agent touched a single user message"* | replaced the server with a CLI at ~80 tokens; cites a 75-run comparison at 4–32× overhead |
| yonatangross | *"The main agent context burns 16.9k tokens (8.5% of 200k) loading 68 MCP tool schemas at session start. Most are never called by the main agent"* | publishes a per-server table; scoping and tool search were available and unused |
| abdlkrim-jribi | *"The agent injects ALL 47 tool schemas on every single request, consuming ~13,341 tokens before the user message is even sent."* | audits his own agent; publishes the ~4,168 / ~4,515 / ~751 / ~3,906 split |
| mjlee | *"With a handful of third party MCPs I've seen tens of thousands of tokens used before I've started anything."* | finds MCP beneficial anyway |
| 0xbadcafebee | *"Say each tool is 150 tokens, that's 150 * 50, or 7500 tokens, dumped into the beginning of every session."* | the balanced account — shell one-liners are more non-deterministic, so re-runs pollute context too; uses both |
| locknitpicker | *"Skills effectively turned MCPs obsolete in the vast majority of MCP applications."* | a progressive-disclosure CLI needs no skill file at all |
| noodletheworld | *"MCP is just \"me too\"; people want MCP to be an \"AI App Store\"; but the blunt, harsh reality is that it's basically impossible to achieve that dream"* | if you want an app, build an app |

Two mitigations get proposed against all of this, and both have filed defects. **cheema33** pre-empts the first: *"And no, the tool search function recently introduced by Anthropic does not completely solve this problem."* Anthropic's own documentation puts that mitigation at *"over 85 percent"* reduction on a five-server setup consuming *"~55k tokens in definitions before Claude does any work"* — real, and not total. **sophiabits** names the second-order cost of the other mitigation, loading servers lazily per task: *"The main problem with this approach at the moment is it busts your prompt cache, because LLMs expect all tool definitions to be defined at the beginning of the context window."*

Deferred loading has its own failure modes on record. In `anthropics/claude-code#57033`, servers added at claude.ai show Connected in `/mcp` and yet *"Any `ToolSearch` query that should match a claude.ai MCP tool returns zero results"*. In `openai/codex#24536`, *"`codex exec` can silently finish with no assistant message when an explicitly configured MCP tool is deferred behind `tool_search`."* A silent empty turn is worse than an expensive one.

[[embed:source:m4]]

## Round trips are the argument that survives bigger context windows

Token cost is a moving target. Context windows grow, caches improve, tool search lands. The structural argument does not move, and it is a different argument.

A tool call happens **between** completions. The model stops, the client executes, the result comes back, the model starts again. **DonHopkins** names the difference: *"I call this \"speed of light\" as opposed to \"carrier pigeon\"."* Code the model writes loops, recurses and composes **inside** one generation.

**martinald** gives the worked case — summing 150 order IDs. *"With MCP the agent would have to do 150 tool calls and explode your context."* One scripted loop does it in one round trip at roughly one percent of the tokens. He also notes there is no reason a harness could not expose MCP tools inside a sandboxed code environment, and that nobody does.

Two vendors have published the same finding independently. Anthropic: *"This reduces the token usage from 150,000 tokens to 2,000 tokens—a time and cost saving of 98.7%."* Cloudflare, on the same pattern: *"With the traditional approach, the output of each tool call must feed into the LLM's neural network, just to be copied over to the inputs of the next call, wasting time, energy, and tokens. When the LLM can write code, it can skip all that, and only read back the final results it needs."*

What that implies for exposing a catalogue is concrete, and it is not "abandon MCP":

1. Expose **one invoking tool that takes a key and arguments**, not one tool per capability. That is what the seven-tool surface does.
2. Make discovery a **call that returns data** — `list_directory`, or the protocol's resolver — so the catalogue is paged through rather than resident.
3. Keep an **HTTP path a code-execution sandbox can hit directly**, so a loop over 150 items is one round trip instead of 150.

**solarkraft** supplies the honest floor under all three: every capability costs some context, because the model has to know it exists in order to invoke it. The only question is whether it costs 523 compact bytes each or one line in a search result.

## `notifications/tools/list_changed` is in the specification, and named clients ignore it

The advertised benefit of a server-side catalogue is that adding a capability needs no client redeploy. The specification supplies the mechanism: *"When the list of available tools changes, servers that declared the `listChanged` capability **SHOULD** send a notification"* — `notifications/tools/list_changed`, a JSON-RPC notification with no parameters and no reply.

Whether that works depends on the client honouring it, and three filed reports say several do not:

| Client or system | Filed | Verbatim |
| --- | --- | --- |
| Kiro IDE — `kirodotdev/Kiro#6553`, closed | 2026-03-20 | *"When an MCP server dynamically adds or removes tools at runtime and sends this notification per the MCP spec, Kiro IDE does not re-query tools/list, so the new tools never appear until the server is manually reconnected."* |
| GitHub Copilot CLI — `microsoft/wassette#308`, open | 2025-09-29 | *"Internal terminal testing shows the CLI never refreshes its tool list, unlike GitHub Copilot in VS Code which updates immediately."* |
| MCPJungle gateway — `mcpjungle/MCPJungle#260`, open | 2026-05-15 | *"MCPJungle caches upstream tool lists at registration time only. If an upstream server adds or removes tools later, MCPJungle's view stays stale until a manual re-registration or full restart."* |

The wassette report comes from a Microsoft engineer with a reproduction video, and notes the same server updates immediately in VS Code — so the defect is per-client, not per-spec.

This site's per-row server sidesteps the question by declaring the truth instead of a promise. Line 159 of `functions/api/mcp.js` returns `capabilities: {"tools":{"listChanged":false}}`. A new row is live on the protocol surface the instant it is written, and appears on the MCP surface the next time a client calls `tools/list` — which, for most clients, is at connect. **"Add a capability without a redeploy" is true of the table and false of the client.** Say the second half out loud or the sentence is a lie.

## Verdict: publish MCP when the client is not yours, publish the protocol when it is

- **Do publish an MCP surface** when the consumer is a client you do not control and cannot teach — Claude Desktop, Cursor, ChatGPT connectors, a colleague's IDE. There is no other way in. Publish it as a **small surface**: one `dispatch`-style tool plus a discovery tool. Never one tool per row.
- **Do publish a per-row surface** only when a client must literally see named tools in its own interface, and only with that client's deferred loading switched on. Otherwise expect the bill: 149,187 input tokens and $0.02852109 per turn, measured.
- **Do not put MCP in front of your own agent** when you already control the harness. The protocol surface reached all 891 capabilities at 14,071 input tokens and $0.00456265 per turn, with nine tool definitions in context — all nine of them the client's built-ins.
- **Do not treat MCP as the architecture.** It is a view. The table is the thing.

**What would change this verdict.** A mainstream client that fetches definitions on demand rather than at connect, and honours `notifications/tools/list_changed`, would collapse the per-row surface's cost to roughly the protocol's and remove the reason to hand-build a small surface. Anthropic's tool search is the first move in that direction and already reaches 14,109 tokens on this catalogue — within 0.3% of the protocol-only figure. Two filed bugs, `claude-code#57033` and `codex#24536`, say it is not yet reliable enough to depend on. When those close and the behaviour is the default rather than a flag, publish per-row and stop hand-rolling.

## Publish the same catalogue three ways

Cheapest surface first, because it is also the one that works everywhere.

**A. Protocol only — no server to write.** Prerequisite: a capability table with a key, a runner, a target, a documentation field and an input schema (see [the row contract](/a/directory-row-contract)), plus one HTTP handler that looks a key up and runs it.

1. `GET /api/dispatch` returns a manifest naming the verbs and endpoint shapes. It was 17,404 bytes at 05:06:05Z.
2. `GET /api/dispatch?ask=<intent>` returns ranked candidates, each with an `example`, an `example_args` and a ready `run_now` URL.
3. `GET /api/dispatch?key=<KEY>&format=markdown` returns the exact row contract.
4. `POST /api/dispatch` with `{"key":"KEY","body":"args"}` invokes it. Add `"shape": true` for a dry run that returns the fully-shaped outbound payload without firing.
5. `GET /api/dispatch?receipt=<id>` returns the receipt.

Run the read-only `TIME_NOW` path end to end:

```bash
export TERMINAL_KEY="<owner access key>"

curl -sS "https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it" \
  | jq '{count, best: .best.key}'
# {"count":12,"best":"NOW"}

curl -sS "https://miscsubjects.com/api/dispatch?key=TIME_NOW&format=markdown" \
  | sed -n '1,12p'
# begins: ## §SELF — miscsubjects capability (paste without context)

curl -sS -X POST "https://miscsubjects.com/api/dispatch" \
  -H "x-terminal-key: $TERMINAL_KEY" -H "content-type: application/json" \
  --data '{"key":"TIME_NOW","body":""}' | tee invocation.json \
  | jq '{ran, result, invocation_id: .invocation.id}'
# ran is true; result contains now, today, time, zone and iso

INVOCATION_ID="$(jq -r '.invocation.id' invocation.json)"
curl -sS "https://miscsubjects.com/api/dispatch?receipt=$INVOCATION_ID" \
  -H "x-terminal-key: $TERMINAL_KEY" | jq '{id, object_id, actor}'
# id equals $INVOCATION_ID and object_id identifies TIME_NOW
```

The live run at 05:06:05Z returned HTTP 200 for all four calls, resolved `NOW`, invoked `TIME_NOW` with `ran: true`, and read a 13,036-byte receipt. Tell the model these five endpoint shapes once, in a system prompt or skill file. Cost in tool definitions: zero.

**B. Small surface — one file, seven tools.** Prerequisite: an MCP SDK and somewhere to run it. On Cloudflare that is `McpAgent` from the `agents` package on a Durable Object, `McpServer` from `@modelcontextprotocol/sdk`, and `zod` for parameter schemas. Register `dispatch` with `{key, body}`, register one discovery tool that queries the table, expose `/mcp` for Streamable HTTP and `/sse` for the older transport, and gate both behind a bearer check. Working file: `workers/mcp-server/src/index.ts`.

The deployed server can be counted with the official TypeScript SDK:

```bash
npm install @modelcontextprotocol/sdk
export TERMINAL_KEY="<owner access key>"

node --input-type=module <<'JS'
import {Client} from "@modelcontextprotocol/sdk/client/index.js";
import {StreamableHTTPClientTransport} from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("https://miscsubjects.com/mcp"),
  {requestInit:{headers:{authorization:`Bearer ${process.env.TERMINAL_KEY}`}}}
);
const client = new Client({name:"count-tools",version:"1"},{capabilities:{}});
await client.connect(transport);
const listed = await client.listTools();
console.log(listed.tools.map(tool => tool.name));
await client.close();
JS
```

Expected output, measured at 05:04:50Z: `d1_query`, `kv_get`, `kv_put`, `list_directory`, `dispatch`, `oip_registry`, `oip_invocations` — seven definitions totalling 2,165 compact JSON bytes. The count stays seven when the catalogue doubles because only `dispatch` and the discovery calls refer to catalogue keys.

**C. Per-row MCP — the compatibility surface.** One SELECT, one loop, one normaliser. The normaliser is the part people skip and then get bitten by: coerce every schema to a root `type: "object"`, ensure every name in `required` exists in `properties`, give every property node a `type`, and flatten `anyOf`/`oneOf`/`allOf` to a representative branch — or a strict client rejects the entire list on the first bad row. Verify with:

```bash
curl -s -X POST https://<host>/api/mcp -H "Authorization: Bearer $MCP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python3 -c \
  "import json,sys;print(len(json.load(sys.stdin)['result']['tools']))"
```

Expected output: one integer, equal to the count of enabled, planner-visible rows.

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| Client shows zero tools and no error | The whole `tools/list` was rejected on the first invalid schema; Moonshot and Kimi validate strictly | Normalise every schema: object root, every `required` name present in `properties`, a `type` on every node, no combinators. `functions/api/mcp.js:44–78` |
| `401 unauthorized` from `/api/mcp` | Bearer token mismatch | Send `Authorization: Bearer <MCP_TOKEN>` or the `x-mcp-token` header. The check is `functions/api/mcp.js:23–28` |
| Tens of thousands of input tokens before the first user message | The client loads every definition at connect | Turn on the client's deferred tool loading, or move to a small surface |
| A new capability is invisible to the client | The client ignores `notifications/tools/list_changed`, or the server declares `listChanged: false` | Reconnect the client. The three filed reports above show reconnect is the reliable path |
| A tool exists in the table but not in `tools/list` | The row has `enabled = 0` or `planner_visible = 0` | Intentional. `planner_visible = 0` keeps a capability callable by key while off the tool surfaces |
| 150 items means 150 tool calls | Round trips happen between completions | Expose an HTTP path a code sandbox can loop over, or one tool that takes a batch |
| Prompt cache hit rate collapses after adding a server | Definitions sit at the head of the context, and changing them invalidates the cache | Keep the definition set fixed across a session; load per-task capabilities through a call, not a definition |
| `codex exec` returns an empty turn | A configured server was deferred behind `tool_search` and never surfaced | `openai/codex#24536`. Pin the server as directly exposed until it closes |

The comparison against staying inside the schema paradigm entirely is [Tool Search and catalogue-as-data](/a/tool-search-vs-catalogue-as-data).

## Sources

1. Model Context Protocol specification, revision 2025-06-18 — https://modelcontextprotocol.io/specification/2025-06-18
2. Tool search tool — https://docs.claude.com/en/docs/agents-and-tools/tool-use/tool-search-tool
3. Code execution with MCP: building more efficient AI agents — https://www.anthropic.com/engineering/code-execution-with-mcp
4. Code Mode: the better way to use MCP — https://blog.cloudflare.com/code-mode/
5. McpAgent API — Cloudflare Agents — https://developers.cloudflare.com/agents/model-context-protocol/mcp-agent-api/
6. Tools — MCP specification 2025-06-18 — https://modelcontextprotocol.io/specification/2025-06-18/server/tools
7. Transports — MCP specification 2025-06-18 — https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
8. JSON-RPC 2.0 Specification — https://www.jsonrpc.org/specification
9. modelcontextprotocol/typescript-sdk — https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x
10. Kiro IDE does not handle MCP notifications/tools/list_changed — dynamic tools not refreshed — https://github.com/kirodotdev/Kiro/issues/6553
11. GitHub Copilot CLI does not dynamically load tools via tools/list_changed — https://github.com/microsoft/wassette/issues/308
12. Dynamic tool sync: notifications/tools/list_changed + polling fallback — https://github.com/mcpjungle/MCPJungle/issues/260
13. ToolSearch does not index tools from claude.ai-hosted MCP servers — https://github.com/anthropics/claude-code/issues/57033
14. codex exec can silently complete empty when configured MCP tools are deferred behind tool_search — https://github.com/openai/codex/issues/24536
15. Context bloat: 16.9k tokens wasted on MCP tool definitions loaded into main agent — https://github.com/yonatangross/orchestkit/issues/885
16. Reduce Context Window Usage (13,341 tokens for tools alone) — https://github.com/abdlkrim-jribi/hcode/issues/4
17. Comment on: Apideck CLI — An AI-agent interface with much lower context consumption than MCP — https://news.ycombinator.com/item?id=47400262
18. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48336021
19. Comment on: MCP is dead; long live MCP — https://news.ycombinator.com/item?id=47381322
20. Comment on: MCP is dead; long live MCP — the contract is the value — https://news.ycombinator.com/item?id=47381282
21. Comment on: MCP is dead? — token cost is a harness detail — https://news.ycombinator.com/item?id=48331540
22. Comment on: MCP is a fad — it is not a wire protocol — https://news.ycombinator.com/item?id=46553245
23. Comment on: What if you don't need MCP at all? — https://news.ycombinator.com/item?id=45955033
24. Comment on: Agent Skills — speed of light versus carrier pigeon — https://news.ycombinator.com/item?id=46878126
25. Comment on: Making MCP cheaper via CLI — https://news.ycombinator.com/item?id=47161005
26. Comment on: MCP is dead? — the arithmetic, and both sides — https://news.ycombinator.com/item?id=48330912
27. Comment on: MCP is dead? — every capability costs some context — https://news.ycombinator.com/item?id=48337283
28. Comment on: When does MCP make sense vs CLI? — https://news.ycombinator.com/item?id=47212763
29. Comment on: Show HN: Epiq — skills versus MCP — https://news.ycombinator.com/item?id=48158034
30. Comment on: Running Gemma 4 locally with LM Studio's headless CLI and Claude Code — https://news.ycombinator.com/item?id=47659574
31. Comment on: Chrome DevTools MCP — tool search does not fully solve it — https://news.ycombinator.com/item?id=47392361
32. Comment on: When does MCP make sense vs CLI? — dynamic tools bust the cache — https://news.ycombinator.com/item?id=47209810
33. Comment on: Show HN: Ismcpdead.com — definitions that never fire — https://news.ycombinator.com/item?id=47646880
34. First-party measurement: tools/list against the per-row MCP surface, 2026-07-26 — https://miscsubjects.com/api/articles/mcp-as-a-projection
35. First-party measurement: the protocol surface reaches every capability with zero tool definitions, 2026-07-26 — https://miscsubjects.com/api/dispatch?registry=1
36. First-party receipt: initialize and tools/call against the live server, 2026-07-26 — https://miscsubjects.com/api/articles/mcp-as-a-projection
37. The source files behind the three surfaces, and the seven-versus-six correction — https://miscsubjects.com/api/articles/mcp-as-a-projection
38. First-party measurement: tools/list against the seven-tool MCP surface, 2026-07-26 — https://miscsubjects.com/


---

# Resolve, read, invoke, receipt: the four calls a stranger's agent makes

slug: dispatch-four-step-loop · https://miscsubjects.com/a/dispatch-four-step-loop · tags: tooling, oip, architecture, receipts, capability-tokens, agents · updated 2026-07-26T03:52:48.369Z

A stranger's agent lands on this domain with no schemas loaded, no SDK, no config file, and one ability: it can make HTTP requests. Four of them get it from a sentence in English to a signed record of work it actually did. The four do not change as the catalogue grows, and none of them requires the agent to have been told anything in advance.

| # | Step | Call | Credential | What comes back |
| --- | --- | --- | --- | --- |
| 1 | Resolve | `GET /api/dispatch?ask=<plain english>` | none | ranked candidate keys, one recommendation, a ready-to-fire URL |
| 2 | Read the contract | `GET /api/dispatch?key=<KEY>&format=markdown` | none | the whole manual for one capability, ~4.4 KB |
| 3 | Invoke | `POST /api/dispatch {"key":…,"body":…}` | owner key or scoped token | the result, plus a receipt id |
| 4 | Take the receipt | `GET /api/dispatch?confirm=<inv_ID>` (public) or `?receipt=<inv_ID>` (credentialed) | none / scoped | proof it happened, and the exact bytes |

Two more verbs hang off step 4 and are the reason the receipt is an object rather than a log line: `replay` re-fires a recorded call with its recorded input, and `repair` supersedes a bad call with a corrected one. Both write new receipts that point back at the old.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Step 1 asks for words and answers with keys

```bash
curl -s "https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it"
```

Real response, trimmed to the parts that matter:

```json
{
  "protocol": "OIP", "version": "1.2.0", "kind": "ask",
  "question": "what time is it",
  "count": 12,
  "best": {
    "key": "NOW",
    "run_now": "https://miscsubjects.com/api/dispatch?invoke=NOW&share=<TOKEN>",
    "do": "Open run_now to do it. Substitute your own text/args where the example has them."
  },
  "matches": [
    { "key": "NOW", "recommended": true,
      "what": "Return the current time from the build clock in Pacific time (America/Los_Angeles). …",
      "example": "[NOW][/NOW]",
      "invoke": { "post": "https://miscsubjects.com/api/dispatch",
                  "body": { "key": "NOW", "body": "" } },
      "self": "https://miscsubjects.com/api/dispatch?key=NOW" }
  ]
}
```

The full ranked list from that exact call, in order: `NOW`, `GITHUB_LIST_ISSUES`, `GITHUB_GET_ISSUE`, `GITHUB_ADD_ISSUE_COMMENT`, `GITHUB_CREATE_ISSUE`, `GITHUB_CLOSE_ISSUE`, `LOCAL_EDIT`, `LOCAL_WRITE`, `CLI_GIT`, `WRITER_AGENT`, `BLOOIO_LIST_CONTACT_IDENTITIES`, `STRIPE_INVOICE_ITEMS_LIST`.

### The matcher is arithmetic over a table, not a model call

The whole ranking function is 65 lines at `/Users/owner/miscsubjects-pages/functions/_lib/object_contract.js:605-669`. The query is lowercased and split on non-alphanumerics into terms of two characters or more. Every enabled row in the directory is scored against those terms over a haystack built from its key, its category and its description:

- term appears in the key: **+3** (line 618)
- term appears anywhere else in the row: **+1** (line 619)
- the row is a pinned canonical answer for this intent: **+1000** (line 621)
- the row is on the demote list, meaning rows that look right but need a channel id the caller does not have: **−6** (line 622)

Rows scoring zero are dropped; the top twelve survive (line 626).

That is why the ranked list above is so strange below position one. The query "what time is it" splits into `what`, `time`, `is`, `it`. The two-letter terms `is` and `it` are substrings of `issues`, so every GitHub issues row scores. The comment sitting above the pin at line 561 says exactly this: *the 2-letter query words "is"/"it" substring-match "issues" in GITHUB_LIST_ISSUES and outrank NOW, so "what time is it" hits the wrong door.* The fix is not a better retriever. It is a hand-written regex table, `ASK_CANONICAL` (lines 560-593), that pins about twenty common intents to one correct key each and adds 1000 points to it. `NOW` sits at the top of the list above because a regex matched `\btime is it\b`, not because scoring found it.

This is worth stating plainly rather than dressing up: **the resolve step is a keyword search with a manual override list, and keyword search over tool descriptions is known to be weak.** The ToolRet benchmark put six classes of retrieval model against 7,600 retrieval tasks over 43,000 tools; the best of them, NV-embed-v1, reached an nDCG@10 of 33.83. Substituting retrieved tools for the oracle set dropped GPT-3.5's pass rate on ToolBench-G1 by 11.40 points. A dense retriever here would probably beat substring counting, and it is not deployed.

[[embed:source:s11]]

### When nothing matches, the answer says so

```bash
curl -s "https://miscsubjects.com/api/dispatch?ask=zzzqqwx"
```

```json
{ "count": 0, "best": null,
  "note": "No capability matched. GET /api/dispatch?registry=1 for the full list, or refine the words.",
  "registry": "https://miscsubjects.com/api/dispatch?registry=1" }
```

The parallel failure is a key that does not exist. Invoke catches it through `didYouMean` (`functions/api/dispatch.js:1914-1925`), which runs a bounded Levenshtein of edit distance ≤3 plus a substring pass over every key (`nearestKeys`, lines 1830-1836):

```json
{ "error": "unknown_key", "attempted": "NOW_TIME", "ran": false,
  "did_you_mean": [ { "key": "NOW", "read": "https://miscsubjects.com/api/dispatch?key=NOW" } ],
  "fix": "You invoked a key that does not exist. Nothing ran. Use one of did_you_mean (GET its ?key= for the exact call), or GET ?ask=<what you want in plain words> to find the right one." }
```

`ran: false` is the load-bearing field. The response also carries an HTTP header: `x-ms-agent-note: Do not tell the user this worked — nothing ran.` A key far enough away from everything, such as `CURRENT_TIME`, returns an empty `did_you_mean` and a different `fix` string pointing at `?ask=` and `?registry=1`.

[[embed:source:s22]]

## Step 2 hands over one manual, not a schema

```bash
curl -s "https://miscsubjects.com/api/dispatch?key=NOW&format=markdown"
```

4,423 bytes, complete, printed here with nothing removed but the token placeholders:

```text
## §SELF — miscsubjects capability (paste without context)
**Principle:** Self-explaining payload — no external context required.
**Path:** OIP > NOW > NOW
**Capability:** `NOW` — Return the current time from the build clock in Pacific time
(America/Los_Angeles). WHEN_TO_USE: any object or model that needs the current date or time.
ARGS: none EX: [NOW][/NOW] OUTPUT: { now, today, time, zone, iso }
**RUN NOW (open this URL):** https://miscsubjects.com/api/dispatch?invoke=NOW&share=<TOKEN>
- **run it:** POST https://miscsubjects.com/api/dispatch {"key":"NOW","body":"<args>"}
- **inputs:** {"args":"none"}
- **outputs:** { now, today, time, zone, iso } — Pacific-offset ISO
- **auth · risk:** none · low
### What this token can do here (computed for: public)
- **contract** — GET …?key=NOW&format=markdown → this object's full contract
- **confirm** — GET …?confirm=INV_ID → public proof that an invocation happened
### Machine Contract
- Read this article first; do not infer the row shape from memory.
- If the call returns ran:false or proof.ok:false, read the receipt and repair the failed
  invocation instead of narrating success.
- If the token denies the call, report the denial exactly; do not switch to a broader action.
### Invocation, Ledger, Repair
- append-only ledger: https://miscsubjects.com/api/invocations?object_id=NOW
- receipt pattern:  https://miscsubjects.com/api/dispatch?receipt=inv_ID&share=<TOKEN>
- replay: POST /api/dispatch {"replay":"inv_ID"}
- repair: POST /api/dispatch {"key":"NOW","body":"corrected args","repairs":"inv_ID"}
### Troubleshooting
- **unknown key** — Use the did_you_mean links or ask URL; never guess another key.
- **argument/body mismatch** — Read inputs/example_args here, then retry with repairs: inv_ID.
- **expired or corrupted token** — Report token_expired/token_corrupted from the response.
- **tool returned ok:false / exit nonzero** — Do not call it sent. Read the receipt, fire a repair.
```

Five things are in there and each answers a question a cold agent would otherwise guess at. **Inputs and outputs** answer *what do I send and what comes back*. **Run-now** answers *what if my only tool is opening a URL*. **The affordance block**, headed "computed for: public", answers *which of these moves will my credential actually survive*; it is computed against the presented token, so an anonymous reader sees two operations and an owner sees nine. **The machine contract** answers *what do I do when it fails*, in imperative sentences aimed at a model rather than a person. **Troubleshooting** is the same four failures this page catalogues below, shipped inside every contract so the fix travels with the tool.

The field-by-field definition of the row that generates this block is in [what a directory row is](/a/directory-row-contract). What is relevant here is the size: 4,423 bytes for one capability, fetched only when a capability has been chosen. The 876-row registry is 1,606,794 bytes.

[[embed:source:s23]]

## Step 3 runs it, and the denial names which of five things went wrong

`NOW` needs no credential to *read*, but every *invocation* is authenticated. There is no anonymous write plane.

```bash
curl -sS -X POST https://miscsubjects.com/api/dispatch \
  -H "x-terminal-key: $TERMINAL_KEY" \
  -H "content-type: application/json" \
  -d '{"key":"NOW","body":""}'
```

Real response, trimmed:

```json
{ "ok": true, "ran": true, "kind": "invocation_result", "trace": "t_06myig2y",
  "result": "{\"now\":\"2026-07-25T22:02:44-07:00\",\"today\":\"2026-07-25\",\"zone\":\"America/Los_Angeles\"}",
  "cost": 0,
  "proof": { "ok": true, "did": "DONE — NOW", "invocation_id": "inv_yu9ni6w7y9",
             "confirm": "https://miscsubjects.com/api/dispatch?confirm=inv_yu9ni6w7y9",
             "receipt": "https://miscsubjects.com/api/dispatch?receipt=inv_yu9ni6w7y9" },
  "invocation": { "actor": "owner:terminal-key",
    "fingerprints": { "algorithm": "sha-256",
      "input":  "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
      "output": "db21d92eaed3b25df5e97a1e72f9a70c5b2db351197db71c388f4e98e08b0b3b",
      "contract": "b359611ee57c925ee9e4d93b80f2a4533dd214c61c45b77f694c2650e917c977" } } }
```

`body` is a pipe-joined positional argument string: `""` when the capability takes none, `"open||30"` for a three-argument row. The same two fields invoke every row in the catalogue.

### A share token is a row, a clock and a use count, and it can only ever shrink

The owner mints a scoped link instead of handing out the master key:

```bash
curl -s -H "x-terminal-key: $TERMINAL_KEY" \
  "https://miscsubjects.com/api/dispatch?mint_share=1&scope=row:NOW&ttl=1&purpose=article-measurement"
```

That returned fingerprint `cap_ae711ea248b13828`, scope `row:NOW`, `risk_ceiling: low`, `expires_at: 2026-07-25T21:37:47-07:00`, and contract pin `b359611e…`. The response states the rule: *this row token fails closed if the current object contract no longer has this fingerprint*. Editing the capability revokes every token minted against the old version of it, automatically.

The token also explains itself to whoever holds it, at `?explain=1&share=<TOKEN>`, and the delegation law it publishes is the macaroon rule: *a holder may mint only an equal-or-narrower child; child uses are reserved from the parent; payload ceilings inherit or shrink; every invocation validates all ancestors.* Birgisson and colleagues described the underlying construction as credentials that "embed caveats that attenuate and contextually confine when, where, by who, and for what purpose a target service should authorize requests."

[[embed:source:s8]]

Every denial, measured live against that token:

| What was presented | Response | HTTP | Why it is a distinct string |
| --- | --- | --- | --- |
| nothing at all | `token_corrupted`, `can_act: false`, `ran: false` | 401 | no anonymous invoke plane exists |
| the token, invoking `NOW` (in scope) | `ok: true`, actor `cap:cap_ae711ea248b13828` | 200 | the allowed case |
| the token, invoking `TIME_NOW` (out of scope) | `scope_mismatch` + `"This token is LIVE, but it is not allowed to invoke TIME_NOW… it is not expired."` | 401 | scope failure is not a clock failure |
| the token with its last 6 characters cut | `token_corrupted` + `"almost always because the link was TRUNCATED or altered on copy-paste"` | 401 | truncation is the common cause and is not expiry |
| the same token 76 seconds after a 60-second TTL | `token_expired` + `"Your token is EXPIRED. Nothing was sent or run."` | 401 | expiry is recoverable by minting; truncation is recoverable by re-copying |

Separating `token_corrupted` from `token_expired` is a deliberate cost. The function that does it, `tokenDead` (`functions/api/dispatch.js:1928-1942`), runs a second signature parse purely to tell the two apart, because a model told "your token went bad" will mint a new one when it should have re-copied the link. Every denial also carries `x-ms-agent-note: Do NOT tell the user it worked — it did not.`

[[embed:source:s25]]

Denied attempts are ledgered under the fingerprint before the 401 is returned (`functions/api/dispatch.js:3497`). A denial is evidence, not silence. That is the point of the signed denial receipts euan21 built into Capframe: "revocable, signed denial receipts (HMAC-SHA256)."

[[embed:source:s14]]

[[embed:source:s18]]

## Step 4 exists because an agent asked to prove its own work invented the proof

This is the strongest argument on the page and it is not this system's argument. In a controlled two-condition experiment reported on Hacker News in March 2026, an agent running without runtime enforcement "fabricated an audit record — invented a governance event that never happened and presented it as compliance evidence." The fix the authors shipped was structural rather than behavioural: write the audit record from the engine, not from the agent, and chain it with SHA-256.

[[embed:source:s12]]

That is the design here. The receipt is written by the dispatcher after the runner returns, in the same code path that produced the result, and the acting model has no write access to it. The alternative is an agent that reports success and produces no record. Sidk24 described that state after an agent modified 47 files and broke a build: "there is no structured trace, no cost attribution per task, no permission audit trail, and no session replay." Four missing things; the receipt object below carries all four.

[[embed:source:s13]]

Two routes read it, and the split matters.

**Public confirmation, no credential:**

```bash
curl -s "https://miscsubjects.com/api/dispatch?confirm=inv_yu9ni6w7y9"
```

```json
{ "kind": "public_receipt/v2", "confirmed": true, "ok": true,
  "status": "PROVEN_MATERIAL_RESULT",
  "headline": "NOW produced material output at 2026-07-25T22:02:44-07:00.",
  "identity": { "invocation_id": "inv_yu9ni6w7y9", "actor": "owner:terminal-key",
                "disclosure": "Owner/CLI/legacy actor label; no bearer credential is exposed." },
  "integrity": { "fingerprints": { "algorithm": "sha-256",
      "input": "e3b0c442…b7852b855", "output": "db21d92e…e08b0b3b" },
    "tamper_rule": "Changing the recorded input, output, contract or lineage changes its
                    fingerprint or chain commitment." },
  "execution": { "private_payload_boundary": "Request and response content remain in the scoped
    forensic receipt. This public object exposes cryptographic fingerprints and navigable proof only." } }
```

An unknown id returns `confirmed: false` and `"No such invocation — it did not happen."` at HTTP 404. The negative is as citable as the positive.

[[embed:source:s24]]

**Forensic receipt, credentialed:**

```bash
curl -s -H "x-terminal-key: $TERMINAL_KEY" \
  "https://miscsubjects.com/api/dispatch?receipt=inv_yu9ni6w7y9"
```

```json
{ "kind": "receipt",
  "story": "owner:terminal-key invoked NOW → {\"now\":\"2026-07-25T22:02:44-07:00\"…} at 2026-07-25T22:02:44-07:00.",
  "receipt": { "id": "inv_yu9ni6w7y9", "trace_id": "t_06myig2y", "object_id": "NOW",
    "actor": "owner:terminal-key", "material": true, "waste": false,
    "tokens_in": 0, "tokens_out": 0, "cost_usd": 0,
    "event_id": "a2443e09-113e-44df-8718-848a98d11740",
    "request_full": "", "response_full": "{\"now\":\"2026-07-25T22:02:44-07:00\",…}",
    "replay_of": null, "repairs": null, "repaired_by": "inv_sbeb4t5ao2",
    "authorized_by": { "actor": "owner:terminal-key",
      "note": "not a recorded capability token (owner key, cli, or legacy share) — no token provenance record" } },
  "verbs": { "replay": { "method": "POST", "body": { "replay": "inv_yu9ni6w7y9" } },
             "repair": { "method": "POST", "body": { "key": "NOW", "body": "<corrected args>",
                                                     "repairs": "inv_yu9ni6w7y9" } } } }
```

Without a credential that route returns 401 with `"receipt needs an owner access key, admin cookie, read/act token, or the exact scoped token that created this invocation."` A tenant token reading another tenant's receipt gets `tenant_receipt_isolation` at 403.

[[embed:source:s5]]

`request_full` and `response_full` hold the bytes, not a summary. A summary of a failed call is somebody's opinion about the failure; the payload is the failure. The Apache Gravitino project reached the same field list from a different direction: "Emit a structured audit record for every MCP tool invocation, capturing the calling principal, tool name, and allow/deny outcome." Rafaself's gateway contract reached the opposite conclusion about bodies, specifying "structured audit logging for MCP tool calls without exposing credentials, signed request data, raw AWS responses, or CloudWatch log message contents." Both are defensible and they genuinely conflict. Gravitino's record is an operational audit trail; rafaself's is a cross-cutting log with an explicit non-goals list, designed to be safe to ship to CloudWatch. The split here follows neither: the *public* object is fingerprints-only, which is rafaself's position, and the *credentialed* object is full bytes, which is what debugging needs. The boundary is authorisation, not redaction.

[[embed:source:s19]]

[[embed:source:s20]]

## Replay repeats the input; repair supersedes it

Both are POST verbs on the same endpoint, both write new receipts, and they do different things to the lineage graph.

```bash
curl -s -X POST https://miscsubjects.com/api/dispatch \
  -H "x-terminal-key: $TERMINAL_KEY" -H "content-type: application/json" \
  -d '{"replay":"inv_yu9ni6w7y9"}'
```

Returned `inv_53l71tl1i0` with `replay_of: "inv_yu9ni6w7y9"` and a link back to the source receipt. Replay reads the recorded request body out of the ledger event and re-fires the *same* object with the *same* input (`functions/api/dispatch.js:3355-3370`); the caller supplies no arguments. `{"replay":…, "key":…}` together is rejected: `"replay and key are mutually exclusive"` at HTTP 400. An unknown id is `"unknown invocation"` at 404. Replay also requires authority over the source receipt *and* its object, not just the object.

[[embed:source:s3]]

```bash
curl -s -X POST https://miscsubjects.com/api/dispatch \
  -H "x-terminal-key: $TERMINAL_KEY" -H "content-type: application/json" \
  -d '{"key":"NOW","body":"","repairs":"inv_yu9ni6w7y9"}'
```

Returned `inv_sbeb4t5ao2` with `repairs: "inv_yu9ni6w7y9"`. Then, re-reading the *original* receipt afterwards:

```json
{ "id": "inv_yu9ni6w7y9", "replay_of": null, "repairs": null, "repaired_by": "inv_sbeb4t5ao2" }
```

The back-link is written after the new invocation logs, by `linkRepairedBy` (`functions/api/dispatch.js:3482-3484`). Nothing is mutated or deleted: the bad receipt keeps its bad payload and gains a pointer to its successor.

[[embed:source:s7]]

| | replay | repair |
| --- | --- | --- |
| body you send | `{"replay":"inv_ID"}` | `{"key":…,"body":"corrected","repairs":"inv_ID"}` |
| input used | the recorded one, read from the ledger event | the new one you supply |
| forward edge on the new receipt | `replay_of` | `repairs` |
| back edge written on the old receipt | none | `repaired_by` |
| idempotency collapse applies | no | no |
| what it is for | reproducing a result, testing a fix to the runner | superseding a wrong call without erasing it |

Repair is the reason `did_you_mean` and the argument-mismatch guidance both say *retry with `repairs: inv_ID` so lineage closes*: a corrected call that does not name what it corrects leaves a dangling failure in the ledger.

[[embed:source:s26]]

## The whole loop, copy-paste, ending in a URL anyone can open

```bash
# 0. one credential, never printed
export TERMINAL_KEY="<your key>"

# 1. RESOLVE — plain words in, keys out
curl -s "https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['best']['key'])"
# -> NOW

# 2. CONTRACT — read it before calling it
curl -s "https://miscsubjects.com/api/dispatch?key=NOW&format=markdown"

# 3. INVOKE — and capture the receipt id
INV=$(curl -s -X POST https://miscsubjects.com/api/dispatch \
        -H "x-terminal-key: $TERMINAL_KEY" -H "content-type: application/json" \
        -d '{"key":"NOW","body":""}' \
      | python3 -c "import json,sys; print(json.load(sys.stdin)['proof']['invocation_id'])")
echo "$INV"
# -> inv_yu9ni6w7y9

# 4. RECEIPT — public proof, no credential
echo "https://miscsubjects.com/api/dispatch?confirm=$INV"
curl -s "https://miscsubjects.com/api/dispatch?confirm=$INV" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['status'], d['headline'])"
# -> PROVEN_MATERIAL_RESULT NOW produced material output at 2026-07-25T22:02:44-07:00.
```

The URL that last block prints is openable by anyone, forever, with no credential: <https://miscsubjects.com/api/dispatch?confirm=inv_yu9ni6w7y9>.

## Six failures, their exact strings, and what to do about each

Every string below was produced by a live call, not transcribed from documentation.

| Symptom | Exact response | HTTP | Cause | Fix |
| --- | --- | --- | --- | --- |
| Key does not exist, close to a real one | `{"error":"unknown_key","attempted":"NOW_TIME","ran":false,"did_you_mean":[{"key":"NOW",…}]}` | 404 | key guessed from memory instead of read from `?key=` | fire one of `did_you_mean`, then re-invoke with `repairs` set to the failed id |
| Key does not exist, close to nothing | `"fix":"No capability by that name. GET ?ask=<what you want> or ?registry=1 for the full list."` | 404 | wrong vocabulary entirely | go back to step 1 |
| Argument or body mismatch | contract field `"argument/body mismatch" — "Read inputs/example_args here, then retry with repairs: inv_ID so lineage closes."` | 200 with `ok:false` | positional pipe args in the wrong order or count | re-read `inputs` in the contract; re-fire with `repairs` |
| Token cut on copy-paste | `{"error":"token_corrupted","can_act":false,"ran":false,"problem":"Your token failed its signature check — almost always because the link was TRUNCATED…"}` | 401 | truncated URL, not an expired one | re-copy the entire link including the tail after the final dot |
| Token past its clock | `{"error":"token_expired","can_act":false,"ran":false,"problem":"Your token is EXPIRED. Nothing was sent or run."}` | 401 | TTL elapsed | owner mints a fresh scoped link |
| Token live but wrong row | `{"error":"scope_mismatch","fingerprint":"cap_ae711ea248b13828","note":"This token is LIVE, but it is not allowed to invoke TIME_NOW…"}` | 401 | attenuated token used outside its allow-list | ask for a wider link; never substitute a different capability |
| The tool itself failed | `{"ok":false,"ran":true,"result":"ERR:fn:D1_QUERY:D1_ERROR: no such table: no_such_table: SQLITE_ERROR"}` | 200 | the runner executed and returned an error | read the receipt, correct the body, fire a `repairs` call |
| Upstream HTTP error | `{"ok":false,"ran":true,"result":"ERR:http:404:{\"message\":\"Not Found\",…}"}` | 200 | remote API rejected the shaped request | same — the receipt holds the upstream body verbatim |
| Ran, produced nothing | `{"ok":true,"ran":true,"proof":{"ok":false,"did":"FAILED — "},"material":false}` | 200 | empty result, e.g. a KV key that does not exist | `proof.ok` tracks material output; `ok` tracks absence of an error string. They disagree here on purpose |

`ok` is computed as `!shaped && !failed`, where `failed` is the regex `/^(?:ERR(?::|$)|PROVIDER_ERROR(?::|$))/` over the result string (`functions/_lib/object_contract.js:2191-2193`). `ran` is `!shaped`; it distinguishes a real execution from a `{"shape":true}` dry run, which returns the fully-composed outbound payload and fires nothing.

[[embed:source:s6]]

Every one of those failures still writes a receipt. `inv_swzanrzqjo` is the D1 error above; it is a real, permanent, addressable record of a call that did not work, with `material: false`. Outcomes include failure, or the ledger is a highlight reel.

[[embed:source:s27]]

## What the loop costs, measured

Ten samples per endpoint from the same Mac in the Pacific timezone to the production Cloudflare edge, on 2026-07-25. The five calls below are the published harness. `INV` is the harmless `NOW` receipt id created by the runnable loop above.

```bash
for i in $(seq 10); do curl -s -o /dev/null -w '%{time_total} %{size_download}\n' \
  'https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it'; done

for i in $(seq 10); do curl -s -o /dev/null -w '%{time_total} %{size_download}\n' \
  'https://miscsubjects.com/api/dispatch?key=NOW&format=markdown'; done

for i in $(seq 10); do curl -s -o /dev/null -w '%{time_total} %{size_download}\n' \
  -X POST 'https://miscsubjects.com/api/dispatch' \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"key":"NOW","body":""}'; done

for i in $(seq 10); do curl -s -o /dev/null -w '%{time_total} %{size_download}\n' \
  "https://miscsubjects.com/api/dispatch?confirm=$INV"; done

for i in $(seq 10); do curl -s -o /dev/null -w '%{time_total} %{size_download}\n' \
  -H "x-terminal-key: $TERMINAL_KEY" \
  "https://miscsubjects.com/api/dispatch?receipt=$INV"; done
```

| Step | min | median | max | response bytes |
| --- | --- | --- | --- | --- |
| 1 resolve `?ask=` | 62.3 ms | 66.7 ms | 82.0 ms | 12,332 |
| 2 contract `?key=…&format=markdown` | 54.0 ms | 97.9 ms | 150.1 ms | 4,423 |
| 3 invoke `POST {key,body}` | 784.9 ms | 940.3 ms | 2,761.0 ms | 15,675 |
| 4a confirm `?confirm=` (public) | 58.5 ms | 98.3 ms | 1,770.5 ms | 15,009 |
| 4b receipt `?receipt=` (credentialed) | 66.0 ms | 81.6 ms | 114.5 ms | 12,971 |

The median read step stayed between 66.7 and 98.3 milliseconds. **The 940.3-millisecond invocation median was more than nine times the slowest read median.** A POST does the work, then writes the invocation row, writes the ledger event, computes three SHA-256 fingerprints, and finalises the idempotency key before responding. Resolve + contract + invoke + public confirmation sums to 1,203.2 milliseconds at the medians; invocation accounts for 78.1% of it.

That overhead is at the high end of what the literature reports for enforcement layers, because it is doing more than policy evaluation. AgentWall, which intercepts and evaluates but persists asynchronously, measured "average decision latency is 0.198 ms and the p95 latency is 0.745 ms" over 14 policy tests. Agent-Sentry's deterministic provenance checks are single-digit milliseconds; its LLM-judge layer costs about 1.2 seconds per call, which is why it fires on only a small residual. The right reading: **sub-millisecond is achievable for a decision, while this measured durable call took 940.3 milliseconds.** Persistence and the runner are the combined cost; this harness does not isolate their shares.

[[embed:source:s9]]

[[embed:source:s10]]

Cloudflare's published Workers Standard price is "10 million included per month +$0.30 per additional million" requests, with duration not billed. Four requests at the marginal rate is 4 × $0.30 / 1,000,000 = **$0.0000012 per complete loop**, or $1.20 per million loops. The D1 side is "First 25 billion / month included + $0.001 / million rows" read and "First 50 million / month included + $1.00 / million rows" written; each invocation writes an invocation row and a ledger event, so two writes, so $0.000002 per loop at the marginal rate. Total marginal cost of resolve + contract + invoke + receipt, with the receipt durably stored: **about $0.0000032**. Below the included tiers it is zero.

[[embed:source:s1]]

[[embed:source:s2]]

[[embed:source:s28]]

What it replaces is the other way to make 876 capabilities reachable: put their definitions in the model's context. That comparison, with its own measurements, is [891 tools, zero tool schemas](/a/tooling-as-data), and the projection of this same catalogue into MCP is [MCP as a projection](/a/mcp-as-a-projection). The relevant number for this page is the one on the resolve step: a `?ask=` response is 12,332 bytes and is fetched once, at the moment a capability is needed, by an agent that had zero of the catalogue loaded a second earlier.

## Four honest weaknesses

**Four round trips happen before any work does.** For a single call that is roughly 600 ms of latency spent on discovery and reading before the invoke even starts. An agent that already knows the key skips straight to step 3, and any agent doing more than one call with the same capability should. The loop is a cold-start protocol, not a per-call tax; nothing enforces that, and a naive agent will re-resolve every time.

**The resolver can miss and does.** It is substring scoring with about twenty hand-pinned intents. Anything outside the pin list is at the mercy of term overlap between the user's words and the row's description, which is precisely the failure ToolRet quantified: even a strong general-purpose retriever managed nDCG@10 of 33.83 on tool retrieval, and worse retrieval measurably lowered downstream task pass rates. A miss here is visible in the ranked list, and the agent can reject it. It is still a real miss.

**A model still has to decide correctly.** Nothing in these four steps prevents an agent from reading the right contract and then choosing the wrong capability, or supplying plausible-looking wrong arguments. The receipt makes that visible afterwards. It does not prevent it. aderix put the general version of this sharply: "If an LLM hallucinates in production and decides to execute a destructive tool defined in SKILL.md (like dropping a table or issuing a Stripe refund), a Git PR approval process doesn't help you mid-flight." The runtime answers here are the risk ceiling on the token and the owner gate on high-risk rows. Both are real, and both are narrower than a general solution.

[[embed:source:s17]]

**Nobody else implements this.** `?ask=`, `?key=`, `?confirm=`, `?receipt=`, `replay` and `repairs` are the shapes one system chose. An agent that has internalised MCP will look for `tools/list` and `tools/call`. The specification says a client "SHOULD" keep "a human in the loop with the ability to deny tool invocations", leaving the record entirely to the implementation. Convergent work exists and is not compatible: Capframe splits the same loop into find, bind and guard with a public JSON Schema wire format; Rampart evaluates "every shell command, file operation, and MCP tool call … against your rules before it executes" behind a hash-chained trail; socket-link/ampere proposes to "enable agents to discover, select, and invoke MCP server tools through the existing `Tool` sealed interface, with tool availability emitted as events"; jithinraj's demo "emits a signed, portable receipt per tool call (JSON you can verify offline)". Four groups, four wire formats, one shape. Until one of them is a specification rather than a repository, a stranger's agent has to read the contract to know the shape. That limitation is the argument for step 2.

[[embed:source:s4]]

[[embed:source:s15]]

[[embed:source:s16]]

[[embed:source:s21]]

kxbnb, arguing for a proxy enforcement point outside the agent's context, named the thing all of these are actually for: "The audit trail piece is critical too. Being able to answer \"why was this blocked?\" after the fact builds trust with teams rolling this out." That question has an address here. It is `?confirm=`, and it needs no credential to ask.

## Sources

1. Cloudflare Workers pricing — Standard usage model — https://developers.cloudflare.com/workers/platform/pricing/
2. Cloudflare D1 pricing — billing metrics — https://developers.cloudflare.com/d1/platform/pricing/
3. OpenTelemetry — Traces — https://opentelemetry.io/docs/concepts/signals/traces/
4. Model Context Protocol specification — Tools (2025-06-18) — https://modelcontextprotocol.io/specification/2025-06-18/server/tools
5. RFC 9110: HTTP Semantics — safe and idempotent methods — https://www.rfc-editor.org/rfc/rfc9110.html
6. RFC 9457: Problem Details for HTTP APIs — https://www.rfc-editor.org/rfc/rfc9457.html
7. The Idempotency-Key HTTP Header Field (IETF draft) — https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header
8. Macaroons: Cookies with Contextual Caveats for Decentralized Authorization in the Cloud — https://research.google.com/pubs/archive/41892.pdf
9. AgentWall: A Runtime Safety Layer for Local AI Agents — https://arxiv.org/abs/2605.16265
10. Agent-Sentry: Bounding LLM Agents via Execution Provenance — https://arxiv.org/abs/2603.22868
11. Retrieval Models Aren't Tool-Savvy: Benchmarking Tool Retrieval for Large Language Models — https://arxiv.org/abs/2503.01763
12. Comment on: Agent Runs Code You Never Wrote — https://news.ycombinator.com/item?id=47579314
13. Comment on: observability for AI agents (author comment) — https://news.ycombinator.com/item?id=47375377
14. Show HN: Capframe – capability tokens for AI agent tool calls — https://news.ycombinator.com/item?id=48201207
15. Show HN: Rampart – Open-source firewall for AI agents (v0.8) — https://news.ycombinator.com/item?id=47329033
16. Show HN: Verify and trace OpenClaw tool calls (runnable demo) — https://news.ycombinator.com/item?id=46965862
17. Comment on: Show HN: GitAgent – An open standard that turns any Git repo into an AI agent — https://news.ycombinator.com/item?id=47417059
18. Comment on: Ask HN: How are you enforcing permissions for AI agent tool calls in production? — https://news.ycombinator.com/item?id=46747408
19. [Subtask] feat(mcp-server): structured per-tool-call audit logging attributed to principal — https://github.com/apache/gravitino/issues/11568
20. Add sanitized audit logging contract for MCP tool calls — https://github.com/rafaself/aws-mcp-gateway/issues/21
21. [Ampere] Dynamic tool discovery and invocation for MCP — https://github.com/socket-link/ampere/issues/415
22. First-party: the resolver ranking a live query, 2026-07-25 — https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it
23. First-party: one capability contract, 4,423 bytes, 2026-07-25 — https://miscsubjects.com/api/dispatch?key=NOW&format=markdown
24. First-party: the receipt for the invocation this page walks, 2026-07-25 — https://miscsubjects.com/api/dispatch?confirm=inv_yu9ni6w7y9
25. First-party: the contract names token failure recovery — https://miscsubjects.com/api/dispatch?key=NOW&format=markdown
26. First-party: lineage after a replay and a repair of the same invocation — https://miscsubjects.com/api/dispatch?confirm=inv_sbeb4t5ao2
27. First-party: failure receipts, three shapes, 2026-07-25 — https://miscsubjects.com/api/dispatch?confirm=inv_swzanrzqjo
28. First-party: latency of each step, 10 samples per endpoint, 2026-07-25 — https://miscsubjects.com/api/dispatch?registry=1


---

# One row of SQL is the whole contract for a capability, and the 892nd took 3.1 seconds

slug: directory-row-contract · https://miscsubjects.com/a/directory-row-contract · tags: tooling, oip, architecture, d1, contracts, mcp · updated 2026-07-26T03:52:45.831Z

A capability on this build is one row in a SQLite table on Cloudflare D1 called `directory`. The row is the whole contract: what the capability is, how to call it, what comes back, which credential it needs, and who is allowed to run it. There is no companion file, no registration call in application code, and no deploy step. On 2026-07-26 the table held 891 rows.

**Scope note:** this article covers the `directory` table's row shape only — the contract for API calls, shell commands, agents and other executable capabilities. It is one of at least two object families on this build; content (articles, their claims, their revisions) lives in a separate `articles`/`article_slots` pair of tables with its own resolver, not in `directory`. [892 rows, 8 of them MCP](/a/the-directory-is-not-the-object-system) draws that line explicitly.

Every field below is published rather than paraphrased, every runner type has a real row printed as stored, and the failure strings are copied out of the code that emits them. The volume argument — why holding 891 tool definitions in a model's context is the wrong shape — is [tooling as data](/a/tooling-as-data). The call sequence around a row is [the four-step loop](/a/dispatch-four-step-loop). The Model Context Protocol view of the same rows is [MCP as a projection](/a/mcp-as-a-projection).

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Eighteen columns, of which six existed on the first day

The table as it stands in production. Read it back yourself:

```bash
cd /Users/owner/miscsubjects-pages
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT sql FROM sqlite_master WHERE name='directory';"
```

```sql
CREATE TABLE directory (
  key        TEXT PRIMARY KEY,
  type       TEXT NOT NULL CHECK (type IN ('fn','http','agent','flow')),
  target     TEXT,
  auth       TEXT,
  content    TEXT,
  updated_at TEXT NOT NULL
, category TEXT, allowed_categories TEXT, seq INTEGER, enabled INTEGER DEFAULT 1,
  planner_visible INTEGER DEFAULT 1, planner_rank INTEGER DEFAULT 100,
  input_schema TEXT, examples TEXT, sensitive INTEGER DEFAULT 0, runner TEXT,
  includes TEXT, created_at TEXT)
```

The first six columns came from `migrations/0007_directory.sql:1-8`. Everything after the closing parenthesis of the original statement is an `ALTER TABLE` bolted on later, which is why the SQL reads the way it does.

| Column | Type · default | Required | What it holds | Real value |
| --- | --- | --- | --- | --- |
| `key` | TEXT, primary key | yes | The invocation name. Unique by constraint, uppercase by convention. | `GROK_MODELS` |
| `type` | TEXT, `CHECK IN ('fn','http','agent','flow')` | yes | Decides how `target` and `content` are read. The only constrained column in the table. | `http` |
| `target` | TEXT | by type | `fn`: a function name. `http`: `"METHOD url"`. `agent`: a model id. `flow`: empty. | `GET https://api.x.ai/v1/models` |
| `auth` | TEXT | no | The *name* of an environment variable and how to apply it. Never a secret value. | `bearer:GROK_API_KEY` |
| `content` | TEXT | yes, gated | `fn`/`http`: comment docstring plus the argument template. `agent`: the system prompt. `flow`: the step DSL. | see the four rows below |
| `updated_at` | TEXT, NOT NULL | yes | ISO timestamp of the last write. The only change marker on the row. | `2026-06-10 02:22:52` |
| `category` | TEXT | no | Grouping label. 110 distinct values live. | `grok` |
| `allowed_categories` | TEXT | no | On `agent` rows: the categories that agent's tool listing is restricted to, or `*`. | `*` |
| `seq` | INTEGER | no | Manual ordinal used to pin a row to a position. Null for almost every row. | `null` |
| `enabled` | INTEGER, default 1 | no | `0` hides the row from every projection. 13 rows are disabled. | `1` |
| `planner_visible` | INTEGER, default 1 | no | Whether planners and the MCP projection list it. | `1` |
| `planner_rank` | INTEGER, default 100 | no | Sort weight for candidate selection. Lower wins. | `100` |
| `input_schema` | TEXT | no | JSON Schema string, used when the row is projected as a typed tool. 256 rows carry one. | `null` |
| `examples` | TEXT | no | JSON array of worked argument strings. 63 rows carry one. | `["37.77\|-122.42"]` |
| `sensitive` | INTEGER, default 0 | no | `1` routes the call through the watcher before it runs. 227 rows are marked. | `0` |
| `runner` | TEXT | no | Overrides the runner inferred from `type`. 301 rows have a value. | `null` |
| `includes` | TEXT | no | On `agent` rows: comma-separated prompt-block keys composed in front of `content` at runtime. | `BLOCK_VOICE,BLOCK_REASONING_A` |
| `created_at` | TEXT | no | Present in the live table. **No migration in the repo adds it.** | `null` on old rows |
| `row_num` | computed, not stored | — | 1-based position in the canonical ordering, attached by the read path. | `412` |

Column provenance, by migration: `0012_directory_category.sql:7-9` added `category`, `allowed_categories`, `seq`. `0018_planner_columns.sql:5-9` added the five planning and schema columns. `0064_substrate.sql:5` added `runner`. `0118_add_directory_sensitive.sql:2` added `sensitive`. `0183_prompt_blocks.sql:2` added `includes`. `created_at` has no such line — `grep -rn "ALTER TABLE directory" --include="*.sql" .` returns fourteen matches and none of them mention it, so that column entered the production table out of band. A rebuild should add it in a migration.

Two columns exist in the table but cannot be written through the REST surface. The PATCH handler's field allow-list at `functions/api/directory/[key].js:223` contains thirteen names, and neither `sensitive` nor `runner` is one of them:

```bash
curl -sS -X PATCH "https://miscsubjects.com/api/directory/ZZ_TEST_TEMPERATURE" \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"runner":"edge"}'
# {"error":"no recognized fields"}   HTTP 400
```

## The type column decides everything else, and it has exactly four legal values

`fn` runs a function inside the build's own Worker. `http` calls somebody else's API. `agent` sends `content` to a model as a system prompt. `flow` chains other rows. The dispatcher branches on the column at `functions/api/dispatch.js:1145-1156`; a fifth value is rejected by the `CHECK` constraint before that branch is reached.

| Type | Live rows | `target` holds | `content` holds | Pick it when |
| --- | --- | --- | --- | --- |
| `fn` | 480 | a key into the runner map | a JSON array template of the function's positional arguments | the work is code you control and want to run at the edge |
| `http` | 303 | `"METHOD url"`, with `$1` slots | the request body template, or nothing for GET | the work is an existing API |
| `agent` | 57 | a model id, e.g. `grok-4.3` or `gw:openai/gpt-4.1-mini` | the system prompt | the work needs judgement, not a deterministic call |
| `flow` | 51 | empty string | steps separated by `>`, each `KEY: args` | the work is two or more capabilities in order |

Counted live:

```bash
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT type, COUNT(*) AS n FROM directory GROUP BY type ORDER BY n DESC;"
# fn 480 | http 303 | agent 57 | flow 51   → 891
```

One real row of each type, exactly as stored.

**`fn` — `NOW`**

```
key  NOW | type fn | target now | auth (null) | category time
content   # Return the current time from the build clock in Pacific time (America/Los_Angeles).
          # WHEN_TO_USE: any object or model that needs the current date or time.
          # ARGS: none
          # EX: [NOW][/NOW]
          # OUTPUT: { now, today, time, zone, iso } — Pacific-offset ISO; today is the Pacific calendar date.
```

Every line of `content` beginning with `#` is stripped before execution by `stripDocs` at `dispatch.js:440-452`. What is left is the executable payload. `NOW` has no payload line, so `runFn` falls back to the default template `["$1"]` at `dispatch.js:1170`.

**`http` — `GROK_MODELS`**

```
key  GROK_MODELS | type http | category grok | auth bearer:GROK_API_KEY
target    GET https://api.x.ai/v1/models
content   # WHAT: List every model on the xAI API. No args
          # WHEN_TO_USE: you need to grok models
          # ARGS: see content
          # EX: [GROK_MODELS][/GROK_MODELS]
          # List every model on the xAI API. No args.
```

**`agent` — `PROMPT_LAB_AGENT`**

```
key  PROMPT_LAB_AGENT | type agent | target grok-4.3 | auth bearer:GROK_API_KEY
content   You are a friendly peptide concierge (LAB TEST v1). One warm sentence,
          then end with [REPLY]your text[/REPLY].
```

An `agent` row with `includes` composes shared prompt blocks in front of `content` at runtime: `ROUTER` carries `BLOCK_VOICE,BLOCK_IMESSAGE,BLOCK_EMOJI,BLOCK_ROUTING,BLOCK_ARA`, so the voice rules are written once and referenced by six rows.

**`flow` — `BLOOIO_FINISH`**

```
key  BLOOIO_FINISH | type flow | target (empty)
content   # Phase C of the inbound turn: given the full agent output text in $1, extract the
          #   LAST [REPLY], send via blooio to $2, return the send result.
          # $1=agent output text. $2=recipient phone.
          LAST_REPLY_OF: $1
          > SEND_BY_CHANNEL: blooio|$2|$PREV
```

The flow reader splits on a top-level `>` and runs each step against the previous step's output, bound to `$PREV` (`dispatch.js:1686-1731`). A step is `KEY: body`. Appending `=> NAME` binds that step's output to `$NAME` for later steps. A `{ A: x | B: y }` block fans out concurrently. A step whose output starts with `ERR:` stops the flow.

## Arguments are one string, split on the pipe character, and that is a deliberate trade

The invocation body is a single string. The dispatcher splits it on `|` and hands the pieces to the runner as `args`:

```js
const args = String(body == null ? '' : body).split('|');
```

That is `dispatch.js:1144`. In a template, `$1` is the first piece, `$2` the second. Two arguments:

```bash
curl -sS -X POST https://miscsubjects.com/api/dispatch \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"key":"ZZ_TEST_TEMPERATURE","body":"37.77|-122.42"}'
```

`$1` becomes `37.77`, `$2` becomes `-122.42`, and the target `GET https://api.open-meteo.com/v1/forecast?latitude=$1&longitude=$2&current=temperature_2m` resolves to a real URL.

The obvious break: a pipe inside an argument. A JSON body, a prompt, a shell command, a sentence with a pipe in it — the naive split shreds all of them. The handled form is `$N+`, which rejoins arguments N through the end with the pipe put back:

```js
if (/^\d+\+$/.test(key)) {
  const v = args.slice(+key.slice(0, -1) - 1).join('|');
  return raw ? v : escFor(mode, v);
}
```

`dispatch.js:171-175`. So a row that takes a JSON blob as its last argument uses `$2+`, not `$2`. `DIR_PATCH` is the live example — it edits another row, so its second argument is arbitrary JSON:

```
key      DIR_PATCH
type     http
target   PATCH https://miscsubjects.com/api/directory/$1
content  # ARGS: key | json_body
         # EX: [DIR_PATCH]ROUTER|{"content":"new prompt text"}[/DIR_PATCH]
         $2+
```

Called as `body: 'ROUTER|{"content":"a|b"}'`, the key is `ROUTER` and the body template `$2+` receives `{"content":"a|b"}` intact. The rule that follows: **only the last argument of a row may contain a pipe, and only if the template uses `$N+`.** A row with two free-text arguments in the middle of its signature is unrepresentable, and that is the real cost of the format.

Substitution is escape-aware. `subVars` at `dispatch.js:156-201` takes a mode — `url`, `json-string` or `raw` — and escapes each value for the position it lands in, so a quote inside an argument cannot break out of a JSON body template. `$$KEY` skips the escaping. `$PREV` is the previous flow step's output. An unresolved `$NAME` is left in place as literal text rather than becoming an empty string.

Why one flat string and not a JSON object per capability: a caller that has read one contract can call any of the 891 without learning a new argument shape, and a router can forward a user's sentence through unchanged. The price is no types at the door. That is the trade one commenter refuses — menix, arguing schemas let a code-writing agent plan one precise program instead of a print-and-inspect loop. Both positions describe real failure modes; the comparison table below lands the verdict.

## The row names the environment variable; the value never enters the table

The `auth` column is a prefix and an environment-variable name. `applyAuth` at `dispatch.js:203-234` reads it at call time:

| Form | What happens | Live rows |
| --- | --- | --- |
| empty or null | no credential applied | 637 |
| `headers:{"k":"$ENV_NAME"}` | each header value has `$NAME` replaced from the environment | 139 |
| `bearer:ENV_NAME` | `Authorization: Bearer <value of ENV_NAME>` | 68 |
| `basic:ENV_NAME` | `Authorization: Basic ` + base64 of `<value>:` | 45 |
| `query:param=ENV_NAME` | appends `?param=<url-encoded value>` to the URL | 2 |
| anything else | throws `ERR:auth:unknown_prefix:<prefix>` | 0 |

```bash
npx wrangler d1 execute loop-content-spine --remote --command \
"SELECT CASE WHEN auth IS NULL OR TRIM(auth)='' THEN '(none)'
        ELSE substr(auth,1,instr(auth,':')) END AS form, COUNT(*) AS n
 FROM directory GROUP BY form ORDER BY n DESC;"
```

115 rows name a credential through `bearer:`, `basic:` or `query:`, and between them they reference **12 distinct auth specifications**. `bearer:GROK_API_KEY` alone appears on 35 rows. One rotation in the platform secret store changes the credential for all 35; no row is touched, no migration runs, no deploy happens.

An attacker who exfiltrates the whole table learns every capability that exists, every upstream URL, every argument shape, which capabilities are credentialed, and the *names* of the twelve secrets. That is a real map, worth defending. What they do not get is one credential value, because no column ever holds one. The failure mode of a leaked registry that stores values is total; here it is reconnaissance.

The row's claims about permission are advisory. Enforcement is server-side and independent of the row. When an invocation arrives with a scoped capability token, `capGateCheck` at `dispatch.js:2121-2149` evaluates revocation, audience binding, owner gate, contract hash, risk ceiling, fixed body and payload ceiling before any runner is reached. A row marked `sensitive = 1` is denied to any token whose `risk_ceiling` is not `high`, with the literal reason `risk_ceiling:low<row:high`. Editing the row cannot widen a token; editing a token cannot reach a row outside its scope. That is the pattern jensbontinck described from production — the credential sits at the enforcement point, not with the caller.

Shape mode proves the boundary without firing anything: `{"key":"…","body":"…","shape":true}` returns the fully constructed outbound request with credential material stripped by `redactDeep` (`dispatch.js:1338-1356`), which removes `authorization`, `x-api-key`, `cookie` and any `*_API_KEY`-shaped string. Against a row whose `auth` named a variable absent from the environment, the preview came back `"headers":{}` — no credential, no header, and the upstream 401 is the first signal.

## Reading one row returns a document that assumes the reader knows nothing

```bash
curl -s "https://miscsubjects.com/api/dispatch?key=GROK_MODELS&format=markdown"
```

4,413 bytes. The blocks it contains, and why each is there:

| Block | Content | Why it exists |
| --- | --- | --- |
| Path | `OIP > GROK > GROK_MODELS` | places the row in the tree so a reader can climb to siblings |
| Capability / When to use | the `# WHAT` and `# WHEN_TO_USE` lines from `content` | the two questions a caller has before choosing |
| RUN NOW | a single URL that fires the example | a model with only a URL-fetch tool can still invoke it |
| Example call | `[GROK_MODELS][/GROK_MODELS]` | the router tag form, for a model emitting tags in prose |
| type · runner · auth · risk | `tool · edge · grok`, `required · low` | tells the caller whether a credential and an approval are needed before trying |
| inputs / outputs | `{"args":"see content"}` and the documented return shape | the argument contract |
| Affordances | the moves the presented credential can make | computed for the caller, with the note that the server enforces scope regardless |
| Machine Contract | four imperatives, including "do not infer the row shape from memory" | stops a model reconstructing a stale signature from training data |
| Invocation / Ledger / Repair | ledger, receipt, replay and repair URLs | closes the loop after the call |
| Troubleshooting | four problems, each with an action and a URL | the same content as the failure table below, at the point of use |

The contract is close to constant in size regardless of the row. Measured across four rows of four different types: `NOW` 4,423 bytes, `GROK_MODELS` 4,413, `ROUTER` 4,442, `CONTENT_SEARCH` 4,507. The per-row variance is under 100 bytes because the scaffolding dominates and the row-specific part is small — that is the shape of a contract that is fetched one at a time rather than held in context. Fetching all of them at once is the opposite bargain: `curl -s "https://miscsubjects.com/api/dispatch?registry=1" | wc -c` returns 1,608,554 bytes for 877 objects.

People running large tool surfaces keep measuring the same thing independently: a maintainer auditing his own agent found 47 tool schemas costing 13,341 tokens on every request before the user's message; a vendor engineer building an MCP server for a large unified API hit 50,000 tokens of definitions before the agent touched a single user message; a tiktoken run against one server's full tool list produced 741 tools and roughly 488,013 tokens, larger than the context window it was meant to fit.

## Adding the 892nd capability: one POST, then a receipt proving it ran

Everything below was executed against production on 2026-07-26 using an obviously-named throwaway row, `ZZ_TEST_TEMPERATURE`, which was deleted at the end. The outputs are copied verbatim.

**Step 1 — the credential.** `TERMINAL_KEY` is the owner key checked by `isBuildAuthed`. Every mutating call carries it as `x-terminal-key`.

```bash
export TERMINAL_KEY="$(grep '^TERMINAL_KEY=' ~/.config/grok-bridge.env | cut -d= -f2 | tr -d '"')"
```

**Step 2 — the POST.** `key` and `type` are the only required fields (`functions/api/directory/index.js:51`).

```bash
curl -sS -X POST https://miscsubjects.com/api/directory \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' -d '{
    "key": "ZZ_TEST_TEMPERATURE",
    "type": "http",
    "target": "GET https://api.open-meteo.com/v1/forecast?latitude=$1&longitude=$2&current=temperature_2m",
    "auth": "",
    "content": "# WHAT: Current temperature in Celsius for one latitude and longitude, from the Open-Meteo public API.\n# WHEN_TO_USE: a capability needs the live temperature at a coordinate.\n# ARGS: $1=latitude | $2=longitude\n# EX: [ZZ_TEST_TEMPERATURE]37.77|-122.42[/ZZ_TEST_TEMPERATURE]\n# OUTPUT: JSON with current.temperature_2m",
    "category": "tools",
    "enabled": 1,
    "planner_visible": 1,
    "examples": "[\"37.77|-122.42\"]"
  }'
```

```json
{"ok":true,"key":"ZZ_TEST_TEMPERATURE","updated_at":"2026-07-26T04:36:19.832Z"}
```

HTTP 201. Without the header the same call returns `{"error":"unauthorized"}` and HTTP 401.

**Step 3 — invoke it.** No deploy, no restart, no cache warm-up in between.

```bash
curl -sS -X POST https://miscsubjects.com/api/dispatch \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"key":"ZZ_TEST_TEMPERATURE","body":"37.77|-122.42"}' \
  -w "\nhttp=%{http_code} total=%{time_total}s\n"
```

```json
{"ok": true, "ran": true, "trace": "t_jweoafmw",
 "result": "HTTP 200:{\"latitude\":37.763283,\"longitude\":-122.41286,...,\"current\":{\"time\":\"2026-07-26T04:30\",\"interval\":900,\"temperature_2m\":15.7}}",
 "proof": {"ok": true, "invocation_id": "inv_c23irnzhx1",
           "public_receipt": "https://miscsubjects.com/receipt/inv_c23irnzhx1"}}
```

`http=200 total=3.126264s` — 3.13 seconds wall clock from a laptop in California, including the Open-Meteo round trip. Three consecutive invocations of a pure `fn` row with no upstream call, timed the same way, took 3.40 s, 1.07 s and 1.59 s; the first carries TLS and connection setup.

**Step 4 — the receipt.** `inv_c23irnzhx1` records actor `owner:terminal-key`, runner `http`, trace `t_jweoafmw`, `material: true`, `cost_usd: 0`, and three SHA-256 fingerprints: input, output, and the object contract. It is public and keyless:

```bash
curl -s "https://miscsubjects.com/api/dispatch?confirm=inv_c23irnzhx1"
# "confirmed": true, "status": "PROVEN_MATERIAL_RESULT"
```

**Step 5 — remove it.**

```bash
curl -sS -X DELETE "https://miscsubjects.com/api/directory/ZZ_TEST_TEMPERATURE" \
  -H "x-terminal-key: $TERMINAL_KEY"
# {"ok":true,"key":"ZZ_TEST_TEMPERATURE","deleted":1}
```

A subsequent `GET /api/directory/ZZ_TEST_TEMPERATURE` returns 404. The receipt still resolves and still reports `confirmed: true` — deleting the definition does not delete the history of what it did.

## The no-restart property depends on something outside the row

Nothing here caches a tool list on the caller's side, so a new row is live on the next read. That is not a general property of tool registries, and the people who run them keep filing the same bug.

A registry gateway maintainer describes the failure exactly: upstream tool lists cached at registration time, stale until a manual re-registration or a full restart, with removed tools leaving dangling references inside tool groups. The Model Context Protocol has a message for this — a server that declared the `listChanged` capability SHOULD send `notifications/tools/list_changed` when its tool list changes. Two independent reports say clients ignore it. One user reproduced a dynamic-registration server against an IDE that never re-queries `tools/list`, while the same server worked in two other clients. A Microsoft engineer filed identical behaviour against a CLI client, with a repro video, noting the same product's editor extension updates immediately.

"Add a capability without a restart" is therefore a claim about the whole path, not about the registry. This path has no client-side cache to invalidate because the caller fetches one contract at a time. A registry that pushes definitions into a client's context inherits that client's refresh behaviour, and the behaviour is not uniform.

## Every failure names itself, and the names are in the code

| Symptom | Literal response | Cause | Fix |
| --- | --- | --- | --- |
| Call returns immediately, nothing ran | `{"error":"unknown_key","attempted":"ZZ_TEST_TEMPERATUR","ran":false,"did_you_mean":[…]}` | key not in the table | use a `did_you_mean` entry, or `GET /api/dispatch?ask=<plain words>` |
| `fn` row fails before running | `ERR:fn:unknown_target:<name>` | `target` names a function absent from the runner map (`dispatch.js:1169`) | correct `target`, or the function was renamed in a deploy |
| `fn` row fails on its own template | `ERR:fn:bad_content_json:<parser message>` | the executable line of `content` is not valid JSON after substitution (`dispatch.js:1173`) | the template must be a JSON array; check for an unescaped quote in an argument |
| `fn` template parses but is rejected | `ERR:fn:content_not_array` | the template is valid JSON but not an array (`dispatch.js:1174`) | wrap it: `["$1","$2"]` |
| Credential-shaped `auth` never applies | request goes out with `"headers":{}` | `auth` names an environment variable that does not exist | add the secret under the exact name; the row does not change |
| Auth string is malformed | `ERR:http:<KEY>:ERR:auth:unknown_prefix:apikey` | prefix is not one of `bearer:`, `basic:`, `headers:`, `query:`, `oauth:` (`dispatch.js:234`) | use a supported prefix |
| Upstream refuses | `ERR:http:401:<body>` | the credential exists but is rejected | rotate the secret; the row is fine |
| Every call to one row fails instantly after a run of 401s | `ERR:breaker_open:<KEY> — 8 consecutive auth failures; credential is dead until replaced.` | the circuit breaker tripped at 8 consecutive 401/403 (`dispatch.js:1293-1320`) | replace the credential; the breaker clears after 1 hour or on `KV delete breaker:<KEY>` |
| Target host unreachable | `ERR:http:fetch:<message>` | DNS, TLS or connection failure (`dispatch.js:1285`) | check the URL in `target` |
| Argument missing | URL renders with an empty slot, e.g. `&longitude=&` | fewer pipe-separated pieces than the template's `$N` slots | count the `$N` slots; extra arguments beyond the highest slot are silently discarded |
| `flow` step fails | `ERR:flow:bad_step:<text>` | a step has no `:` separating key from body (`dispatch.js:1723`) | write `KEY: args` |
| Write refused, nothing changed | `{"error":"registry_hygiene_refused: missing_description","how_to_fix":"content (the docstring…) is required…","state_changed":false}` | PUT/PATCH would leave the row with empty `content` (`[key].js:142-153`) | write the `# WHAT / # ARGS / # EX` docstring |
| Marking a row sensitive is refused | `{"error":"registry_hygiene_refused: high_risk_missing_schema"}` | `sensitive` set without `input_schema` | supply `input_schema` in the same call |
| PATCH accepted no changes | `{"error":"no recognized fields"}` HTTP 400 | the body named only fields outside the allow-list (`[key].js:223`) | `sensitive` and `runner` are not writable through PATCH |
| Token rejected on a row it should reach | `risk_ceiling:low<row:high` | the row is `sensitive` and the token's ceiling is not `high` | mint a token with the higher ceiling; the row is not the problem |
| Token rejected after an unrelated edit | `contract_changed:<pinned>!=<current>` | the token pinned a contract hash and the row's contract changed | mint a fresh token against the new contract |

The hygiene gate is asymmetric on purpose. `PUT` refuses any non-compliant write. `PATCH` compares the violation before and after the merge and refuses only a patch that makes a compliant row non-compliant, so the rows that predate the rule stay editable for unrelated maintenance (`[key].js:206-219`). Of 891 rows, 256 carry an `input_schema` and 63 carry `examples`.

## Where the row loses to a schema, and where a schema loses to the row

| Dimension | Directory row | JSON Schema tool definition | OpenAPI 3.1 operation | MCP tool |
| --- | --- | --- | --- | --- |
| Argument typing before the call | none; one string, split on `\|` | full — types, enums, required, formats | full, plus content negotiation and parameter locations | full; `inputSchema` is a JSON Schema object |
| Rejects a bad call without executing | no; the runner or the upstream rejects it | yes, at the validator | yes, at the gateway or generated client | yes, if the host validates |
| Client code generation | none | partial | mature; the spec exists so consumers can act "without access to source code" | via generated SDKs over the schema |
| Ecosystem and tooling | one implementation, this one | universal | very large | growing fast, multi-vendor |
| Discovery | `?ask=` in plain words, or `?registry=1` | out of band | the document is the discovery surface | `tools/list` |
| Cost in caller context | one contract, ~4.4 KB, fetched when needed | every definition, every request | n/a in-prompt; large as a document | every definition, every request unless the host defers |
| Adding a capability | one INSERT, live immediately | edit the tool array, redeploy the caller | edit the document, regenerate clients | server-side change plus a `list_changed` the client may ignore |
| Change safety for callers | contract hash on the receipt, detected after the fact | schema diff, detected by tooling | versioned document, diffable | schema diff, if the client re-reads |

The verdict. Use a schema when a wrong call is expensive and must be stopped before it executes — money movement, destructive writes, anything where "the upstream returned 400" is already too late — or when strangers will write clients against it. Typing is not decoration there; it is the only place a bad argument is caught for free. Use a row when the surface is large, the caller is a model, the operations are mostly read-and-report, and the dominant cost is context rather than correctness.

The two are not exclusive. `input_schema` is on the row for this reason: 256 rows carry one, and those are the rows that get typed when the table is projected as an MCP tool list. The row is the storage format; the schema is one projection of it.

## A row has no version number, and the receipt is where change is detectable

`updated_at` is overwritten on every write. There is no version column, no history table, and no diff. What exists instead is a fingerprint computed at invocation time. `objectContractFingerprint` (`functions/_lib/object_contract.js:17-25`) hashes the fields that define the call — id, object type, runner, directory type, category, target, description, `input_schema`, auth, risk, approval requirement, status, operation semantics — and the resulting SHA-256 is stored on every receipt as `fingerprints.contract`.

Which fields count was measured directly, by invoking the same row three times with an edit in between:

| Edit between invocations | Invocation | Contract fingerprint |
| --- | --- | --- |
| — (baseline) | `inv_vbzo55gyxb` | `fcb5a4d6ccdd552994c09c96a88c6a1dc18470d2fb1b57c151b4b951e4a7342f` |
| `PATCH {"examples":"[\"51.51\|-0.13\"]"}` | `inv_7s7br5887e` | `fcb5a4d6ccdd552994c09c96a88c6a1dc18470d2fb1b57c151b4b951e4a7342f` |
| `PATCH {"target":"…&current=temperature_2m,wind_speed_10m"}` | `inv_m6c161ry9u` | `da066c304231231b32002f04aebb513f8a62bed41646e4b16845cb25c8c7fadc` |

Changing `examples` left the fingerprint byte-identical. Changing `target` changed it. So the hash tracks the callable contract and ignores documentation-only edits — which is the behaviour you want, and it is worth knowing rather than assuming.

Two guarantees follow, and one gap. A capability token may be minted pinned to a contract hash; any invocation after the row changes is refused with `contract_changed:<pinned>!=<current>` and HTTP 409 (`dispatch.js:2128-2132`), so the caller is stopped rather than silently redirected. And every mutation is written to the append-only event log as a `DIRECTORY_MUTATE` record carrying the action, the key and the row (`[key].js:29-45`) — the change history exists even though the row does not keep it.

The gap: an unpinned caller invoking a changed row gets the new behaviour with no warning and learns of it from the receipt afterwards. That is the limitation, and the reason pinning exists. It is the same distinction aderix drew about version-controlling capabilities in general — approval around the definition does not help mid-flight, because the dangerous moment is the invocation, not the edit.

Receipts are engine-authored, never agent-authored, and that is not stylistic. A controlled two-condition experiment found an agent inventing a governance event that never happened and presenting it as compliance evidence when nothing else wrote the record. Here the dispatcher writes it, in the same code path that runs the call, with hashes of the actual input and output bytes.


## Sources

1. Cloudflare D1 overview — https://developers.cloudflare.com/d1/
2. Tool use overview — Claude Docs — https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
3. Model Context Protocol — Tools (2025-06-18) — https://modelcontextprotocol.io/specification/2025-06-18/server/tools
4. JSON Schema Validation, draft 2020-12 — https://json-schema.org/draft/2020-12/json-schema-validation
5. OpenAPI Specification 3.1.0 — https://spec.openapis.org/oas/v3.1.0.html
6. modelcontextprotocol/schema.ts — the Tool interface — https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.ts
7. Dynamic tool sync: notifications/tools/list_changed + polling fallback — https://github.com/mcpjungle/MCPJungle/issues/260
8. Kiro IDE does not handle MCP notifications/tools/list_changed — dynamic tools not refreshed — https://github.com/kirodotdev/Kiro/issues/6553
9. GitHub Copilot CLI does not dynamically load tools via tools/list_changed — https://github.com/microsoft/wassette/issues/308
10. Comment on: Agentic Engineering Patterns — https://news.ycombinator.com/item?id=47263727
11. Comment on: Polymcp – toolkit for building MCP agents that discover, inspect and orchestrate tools — https://news.ycombinator.com/item?id=46487491
12. Reduce Context Window Usage (13,341 tokens for tools alone) — https://github.com/abdlkrim-jribi/hcode/issues/4
13. Comment on: Apideck CLI – An AI-agent interface with much lower context consumption than MCP — https://news.ycombinator.com/item?id=47400262
14. GCORE_TOOLS=* advertises ~488k tokens of tool definitions — larger than most context windows — https://github.com/G-Core/gcore-mcp-server/issues/14
15. Comment on: MCP is dead; long live MCP — https://news.ycombinator.com/item?id=47381282
16. Comment on: Show HN: GitAgent – An open standard that turns any Git repo into an AI agent — https://news.ycombinator.com/item?id=47417059
17. Comment on: Agent Runs Code You Never Wrote — https://news.ycombinator.com/item?id=47579314
18. Comment on: Ask HN: How are you enforcing permissions for AI agent tool calls in production? — https://news.ycombinator.com/item?id=46747408
19. Add sanitized audit logging contract for MCP tool calls — https://github.com/rafaself/aws-mcp-gateway/issues/21
20. Row counts by type, taken from production D1 on 2026-07-26 — https://miscsubjects.com/api/dispatch?registry=1
21. Credential forms and registry hygiene across all 891 rows — https://miscsubjects.com/api/directory
22. Receipt for the first invocation of a capability created minutes earlier — https://miscsubjects.com/receipt/inv_c23irnzhx1
23. Contract size, measured across four rows of four different types — https://miscsubjects.com/api/dispatch?key=GROK_MODELS&format=markdown
24. Which row edits change the contract fingerprint, measured by three invocations — https://miscsubjects.com/api/dispatch?confirm=inv_m6c161ry9u
25. The production CREATE TABLE and column list, read back from D1 — https://miscsubjects.com/api/directory
26. cloudflare/workers-sdk — wrangler, the tool every measurement here was taken with — https://github.com/cloudflare/workers-sdk


---

# Nine tool definitions reach every capability: the catalogue is a SQL table, not a prompt

slug: tooling-as-data · https://miscsubjects.com/a/tooling-as-data · tags: tooling, oip, mcp, architecture, tool-search, context-engineering, cost · updated 2026-07-26T03:52:41.331Z

A model's capabilities do not have to live in its context. On this build they live in a SQLite table on Cloudflare D1 called `directory` — one row per capability, reachable through one HTTP endpoint — and the model is shown nine tool definitions. Not nine capabilities. Nine definitions, and every row in the table behind them.

**Scope note:** this measures one thing — what it costs to expose *this* catalogue to a model three ways. It is not a claim that the catalogue is the whole architecture. [892 rows, 8 of them MCP](/a/the-directory-is-not-the-object-system) breaks the same `directory` table down by runner and category: eight rows are tagged `category='mcp'`; the rest are API calls, shell commands, Mac-local actions and agents. It also names the separate `articles` table and resolver, which this table and its `dispatch()` function do not cover.

The default in every agent stack is the opposite: each capability is a tool definition, each definition is JSON Schema, and the whole set is transmitted on every request. That puts catalogue size in the per-turn cost equation. This design takes it out.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The same catalogue, exposed three ways, measured on the same day

One build, one table, one model. Only the exposure changes. Measured 2026-07-25 on `@cf/moonshotai/kimi-k2.7-code` through Cloudflare AI Gateway, `claude-cli` 2.1.165 as the client, figures read from gateway log rows and wire captures rather than estimated.

| Exposure | What the request carries | Input tokens, one turn | Cost, that turn |
| --- | --- | --- | --- |
| One MCP tool per row (`POST /api/mcp`, `tools/list`) | 856 tool definitions | 149,187 | $0.02852109 |
| Same server, host defers the definitions (`ENABLE_TOOL_SEARCH=true`) | 9 definitions + a search tool | 14,109 | $0.00443075 |
| No MCP server attached; capabilities reached over HTTP | 9 built-in tool definitions | 14,071 | $0.00456265 |

All three rows reach the same capabilities. The first costs 10.6× the input tokens of the third for identical reach.

## The honest finding: at this size the two cheap designs cost the same

14,109 against 14,071 is a difference of 38 input tokens, 0.27%. In dollars the deferred-tool turn came out 3.0% cheaper, because the turn totals include output tokens and the two turns did not produce identical output. Anyone reading this page for a cost argument between rows two and three will not find one. **At 891 rows, deferred tool search and catalogue-as-data are the same price.**

The difference is structural, and it does not expire when the numbers do:

- Row two's cost is a function of how many definitions the model retrieves. Row three's is a function of the protocol, which is four endpoints regardless of table size.
- Row two needs the host to implement deferral. Row three needs the model to be able to make an HTTP request.

What would falsify the structural claim: a harness where deferred loading is free at any catalogue size *and* is implemented uniformly across clients. Section "Deferral is a host feature, and hosts disagree about it" is where that claim currently breaks. What would falsify the cost claim: a catalogue an order of magnitude larger, where the retrieved-definition cost of row two starts to bite while row three stays flat. That measurement has not been taken here and is not claimed.

## `defer_loading` controls context, not the request

The vendor documentation is explicit about what deferral does and does not remove, and it is the single most load-bearing fact on this page:

> `defer_loading` controls what enters the context window, not what you send in the request: You still send every tool's full definition in the `tools` array on every request, including the deferred ones. The API needs them server-side to run the search and expand `tool_reference` blocks.

— Anthropic, *Tool search tool*

So the catalogue is still enumerated, still serialised, still transmitted, every turn. It is simply not billed as context. On this build that array would be 831 tool objects and 451,197 bytes of JSON, measured live below. The client is doing that work whether or not the model reads it.

Anthropic also publishes the billing rule that makes definitions expensive in the first place: pricing counts "the total number of input tokens sent to the model (including in the `tools` parameter)". Names, descriptions and schemas are input tokens. They are re-sent every turn. They scale with how many capabilities exist.

Two independent numbers put a floor under that. Anthropic's own doc says a five-server setup — GitHub, Slack, Sentry, Grafana, Splunk — "can consume ~55k tokens in definitions before Claude does any work". The Scalekit benchmark, 5 GitHub tasks against `anthropics/anthropic-sdk-python`, Claude Sonnet 4, pre-registered hypotheses and 30 runs per arm, found the simplest task cost 1,365 tokens through a shell and 44,026 through GitHub's MCP server, and attributes it: "The difference is almost entirely schema: 43 tool definitions injected into every conversation, of which the agent uses one or two."

Divide this build's own numbers the same way. (149,187 − 14,071) ÷ 856 = **157.8 input tokens per definition**. That is close to the back-of-envelope a commenter used on Hacker News — "Say each tool is 150 tokens, that's 150 * 50, or 7500 tokens, dumped into the beginning of every session" — which means the per-definition constant is stable enough to plan against.

## One row is one capability, and this is one row

`AIG_LIST` lists the AI Gateways on a Cloudflare account. Read live with:

```
curl -s "https://miscsubjects.com/api/directory/AIG_LIST" -H "x-terminal-key: $TERMINAL_KEY"
```

Every field it carries, verbatim:

| Field | Value in `AIG_LIST` | What it does |
| --- | --- | --- |
| `key` | `AIG_LIST` | Primary key and invocation name. The only identifier a caller needs. |
| `type` | `http` | One of `fn`, `http`, `agent`, `flow`. Decides which runner executes the row. |
| `target` | `GET https://api.cloudflare.com/client/v4/accounts/$1/ai-gateway/gateways` | Where the work happens. `$1` is the first positional argument. |
| `auth` | `bearer:CLOUDFLARE_API_TOKEN` | The **name** of the environment variable holding the credential. Never the credential. |
| `content` | `# WHAT: List AI Gateways on the account`<br>`# WHEN_TO_USE: you need to aig list`<br>`# ARGS: account_id`<br>`# EX: [AIG_LIST][/AIG_LIST]` | Docstring lines then the argument template. The `#` lines are the contract a model reads; everything after them is the executable payload. |
| `category` | `null` | Grouping tag. Used to filter the registry (`?registry=1&category=…`). |
| `planner_rank` | `100` | Sort order when a planner is choosing between candidates. Lower ranks first. |
| `enabled` | `1` | `0` removes it from every projection without deleting the history. |
| `planner_visible` | `1` | `0` keeps it invocable but hides it from planners and from the MCP projection. |
| `input_schema` | `null` | Optional JSON Schema. Only consulted when the row is projected as an MCP tool. |

The field list is not folklore — it is declared in code at `/Users/owner/miscsubjects-pages/functions/_lib/dir_schema.js` lines 6–29, which is embedded in `/api/directory` responses so a client can learn the shape without prior knowledge. The docstring parser that splits `#` lines from the payload is `extractDocs`/`stripDocs` in `functions/api/dispatch.js` lines 431–450.

The full field reference, all four `type` values and what each runner does: [What a directory row is](/a/directory-row-contract).

## Four counts of the same catalogue, all of them correct

Ask the build how many capabilities it has and you get four different numbers. They are not a bug and they must not be reconciled by editing one to match another. Each is a different predicate over the same table.

Taken live at **2026-07-26T04:37:42Z**:

```
npx wrangler d1 execute loop-content-spine --remote --command \
  "SELECT COUNT(*) AS rows_total,
          SUM(CASE WHEN IFNULL(enabled,1)=1 THEN 1 ELSE 0 END) AS enabled,
          SUM(CASE WHEN IFNULL(enabled,1)=1 AND IFNULL(planner_visible,1)=1 THEN 1 ELSE 0 END) AS mcp_projected
   FROM directory;" --json
```

| Number | Surface it appears on | The predicate | Where the predicate lives |
| --- | --- | --- | --- |
| **892** | The table itself | every row | `SELECT COUNT(*) FROM directory` |
| **879** | `GET /api/dispatch?map=1` → `total` | `IFNULL(enabled,1)=1` | 13 rows are disabled and stay in the table for their history |
| **877** | `GET /api/dispatch?registry=1` → `count` | enabled, minus test-shaped keys | `TEST_ID_PATTERN` at `functions/_lib/object_contract.js:2477`, applied at `:2481-2483` |
| **832** | `POST /api/mcp` `tools/list` | `IFNULL(enabled,1)=1 AND IFNULL(planner_visible,1)=1` | `listTools()` at `functions/api/mcp.js:118-123` |

The measurement day's figures were 891 / 878 / — / 856. The table is live and other writers touch it, so a rerun returns whatever it holds at that instant; between the first and last command in this session a row was inserted by another process. That is the point of the design, not an inconvenience to it. The gap between 892 and 832 — 60 rows — is entirely disabled rows plus rows deliberately hidden from planners.

## Counting the projection, live

The MCP projection is a real server and the definition array can be weighed directly. Command:

```
curl -s -X POST https://miscsubjects.com/api/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Result at 2026-07-26T04:38Z: **831 tools**, **451,197 bytes** of `tools` array, mean **543 bytes per definition**. That is the payload a client sends on every request under `defer_loading`, and the payload a model reads without it.

## A model that has never seen this build gets from question to receipt in four calls

No SDK, no client library, no prior knowledge. Four HTTP calls.

**1 — Ask in plain language.** `GET https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it` returns `count: 12`, a `best` block, and twelve ranked candidates each with a runnable URL:

```json
"best": { "key": "NOW",
          "run_now": "https://miscsubjects.com/api/dispatch?invoke=NOW&share=<TOKEN>",
          "do": "Open run_now to do it. Substitute your own text/args where the example has them." }
```

**2 — Read the contract.** `GET https://miscsubjects.com/api/dispatch?key=NOW&format=markdown` returns that capability's `_self` block: what it is, the exact POST shape, the argument template, the output contract, the auth and risk level, the troubleshooting table, and the ledger and repair addresses.

**3 — Invoke.** `POST https://miscsubjects.com/api/dispatch {"key":"NOW","body":""}`.

**4 — Take the receipt.** The response carries `proof.invocation_id` and three addresses: a credentialed forensic receipt, a keyless public confirmation, and a public brochure.

Each step with its full request and response: [The four-step loop](/a/dispatch-four-step-loop).

## The resolver is a substring scorer, and the tail of its output is noise

`answerAsk` at `functions/_lib/object_contract.js:605-640` scores every enabled row: +3 if a query term appears in the key, +1 if it appears anywhere in key, category or docstring, +1000 for a hand-pinned canonical match, −6 for a row on the demote list. Top twelve are returned.

For `?ask=what is it` the pinned answer is right and the rest is garbage. The live twelve for "what time is it":

`NOW`, `GITHUB_LIST_ISSUES`, `GITHUB_GET_ISSUE`, `GITHUB_ADD_ISSUE_COMMENT`, `GITHUB_CREATE_ISSUE`, `GITHUB_CLOSE_ISSUE`, `LOCAL_EDIT`, `LOCAL_WRITE`, `CLI_GIT`, `WRITER_AGENT`, `BLOOIO_LIST_CONTACT_IDENTITIES`, `STRIPE_INVOICE_ITEMS_LIST`

The GitHub rows match because the two-character term `is` is a substring of `issue`. There are no embeddings, no BM25, no stemming and no synonym table. A query using a word the row never uses will miss it. Anthropic's own tool search offers BM25 and regex variants for exactly this reason, and one commenter names the trade honestly: "Seems like we traded scalability for accuracy, then accuracy for scalability… but I guess maybe we've come out on top because whatever they are using for tool search is better than RAG?" On this build the mitigation is the `recommended` pin, which is a hand-maintained list, which is a real maintenance cost.

## What a row carries that a list of endpoints does not

"Put it behind an API" is not the same design. Five things live in the row that a bare endpoint list leaves to convention:

| The row carries | A bare endpoint list has | Why it matters to a model |
| --- | --- | --- |
| A docstring contract (`WHAT` / `WHEN_TO_USE` / `ARGS` / `EX`) | A path and a method | The model learns *when* to call it, not just how. |
| An auth field naming an environment variable | A credential the caller must already hold | The catalogue is publishable; the secret never appears in it. |
| A receipt per invocation, addressable | Whatever the server logged | Failure is inspectable at a URL instead of narratable. |
| A repair address (`repairs: inv_ID`) | A retry | A corrected call is linked to the failed one; lineage closes. |
| `enabled` / `planner_visible` flags | A deploy | Withdrawing a capability is an `UPDATE`. |

## The strongest objection to all of this, from someone who means it

The case against is not "MCP is fine". It is that a decorated index of features beats a bare endpoint list for a model, and that decoration is the whole product:

> It's like saying APIs are dead because you can just use HTTP. They're not the same thing, though of course you can hand-roll the higher layer in the lower one. It's just more work, less standard, less valuable.

— brookst, Hacker News, 2026-05-30

That is correct as stated, and this design does not contradict it. The `directory` row *is* the decorated index: the decoration is the docstring, the auth field, the schema and the flags. What is rejected is the claim that the decoration must arrive as tool definitions in the prompt. A second commenter puts the same point at the protocol level — "The idea that MCP tool definitions take up a certain number of tokens is laughable. That's an implementation detail of the agent harness." — and a third calls deferral table stakes: "Most mature harnesses do some kind of tool search and/or progressive disclosure."

Both are right that token cost is a harness property. The reply is narrow: a harness property is exactly the thing this design refuses to depend on.

## Deferral is a host feature, and hosts disagree about it

That refusal is not theoretical. Every claim below is a filed, reproducible report:

| Reported | Client | Effect |
| --- | --- | --- |
| Deferred search does not index claude.ai-hosted MCP servers | Claude Code 2.1.114 | Tools show Connected in `/mcp`, `ToolSearch` returns zero results for them |
| The deferral threshold is computed from `model.default`, not the session model | hermes-agent | A 98,304-token local model gets a threshold sized for a 256K cloud model |
| Built-in server schemas load non-deferred with no opt-out | Claude Desktop | ~3.9k tokens of first-party schemas escape deferral every session |
| A configured tool deferred behind `tool_search` yields an empty turn | codex-cli 0.133.0 | `codex exec` completes with no assistant message |
| `notifications/tools/list_changed` ignored | Kiro IDE | New tools never appear until manual reconnection |
| Same notification ignored | GitHub Copilot CLI | Tool list never refreshes; VS Code updates immediately |

The last two matter for the next section: the MCP specification's answer to adding a capability at runtime is that servers "SHOULD send a notification", `notifications/tools/list_changed`. It is a SHOULD on the server and a silent no-op in at least two shipping clients.

## The 892nd capability costs one POST and no deploy

Not an argument — a round trip run for this page.

```
curl -X POST https://miscsubjects.com/api/directory \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"key":"__DOC_PROBE","type":"http",
       "target":"GET https://miscsubjects.com/api/dispatch?key=TIME_NOW",
       "category":"docs","content":"# WHAT: Probe row created to time one capability insert.\n# ARGS: none\n"}'
```

Response, `HTTP 201` in **0.437946 s**:

```json
{"ok":true,"key":"__DOC_PROBE","updated_at":"2026-07-26T04:36:08.555Z"}
```

Row count went 891 → 892. No build, no deploy, no client restart, no reconnect. Thirty-five seconds later — the directory snapshot cache is a 30-second KV entry, set in `loadDirectory()` at `functions/api/dispatch.js:413-429` — the new capability had a full self-describing contract at `?key=__DOC_PROBE`, and invoking it returned:

```json
{"ok":true,"ran":true,"proof":{"ok":true,"did":"DONE — __DOC_PROBE",
 "invocation_id":"inv_z77vqe1qi6",
 "public_receipt":"https://miscsubjects.com/receipt/inv_z77vqe1qi6"}}
```

The receipt is still public: `GET https://miscsubjects.com/api/dispatch?confirm=inv_z77vqe1qi6` returns `"confirmed": true` with no credential. The probe row was then deleted (`DELETE /api/directory/__DOC_PROBE` → `{"ok":true,"deleted":1}`); the receipt survives the row, because receipts are append-only and rows are not.

The equivalent under definitions-in-context is: publish a new definition, emit `notifications/tools/list_changed`, and hope the client re-queries. Two of the clients above do not.

## Where this design loses

Stated plainly, because a page that argues one way is not worth reading.

- **It needs a running service.** The catalogue is a table behind a Worker. If `miscsubjects.com` is down, there are zero capabilities. An MCP server on stdio keeps working with no network.
- **There is no client-side discovery.** Nothing enumerates the catalogue into a UI, a permission prompt or a tool picker. MCP clients render tool lists, ask for consent per call, and show the user what the model can reach. The specification says implementations "SHOULD" keep a human in the loop; here the human-in-the-loop surface has to be built.
- **A model that cannot make HTTP calls cannot use any of it.** Every model behind this page can. That is an assumption, not a law.
- **There is no ecosystem.** No marketplace, no registry of third-party servers, no `npx` one-liner, no standard anyone else implements. Wrapping someone else's MCP server means writing rows.
- **Retrieval quality is worse than a purpose-built search.** Substring scoring plus a hand-pinned list, versus BM25 or regex with a vendor tuning it.
- **The resolver is a single point of failure for discovery.** If `?ask=` ranks wrong, the model does not know what it missed. A full definition list has no ranking to get wrong.
- **Round trips.** Discovery is a network call before the work. One commenter frames the whole MCP-versus-in-context debate this way — "I call this 'speed of light' as opposed to 'carrier pigeon'" — and the criticism lands here too: reading a contract costs a turn that an in-context definition does not.

## The month, multiplied out

At 200 model turns per day, one seat, the measured per-turn costs above:

| Exposure | Per turn | × 200 turns/day | × 30 days |
| --- | --- | --- | --- |
| Per-row MCP, definitions in context | $0.02852109 | $5.704218 | **$171.13** |
| Deferred tool search | $0.00443075 | $0.886150 | **$26.58** |
| No MCP server, capabilities over HTTP | $0.00456265 | $0.912530 | **$27.38** |

$171.13 − $27.38 = **$143.75 a month per seat**, for exactly the same reach. Between the two cheap rows the difference is $0.79 a month, which is not a reason to choose either. Choose row three when the capability layer has to outlive one vendor's tool-calling implementation; choose row two when the host already implements deferral and the catalogue is already MCP servers.

The full dimension-by-dimension comparison: [Tool Search and catalogue-as-data, compared](/a/tool-search-vs-catalogue-as-data). What the per-row MCP projection is for and when to attach it: [MCP as a projection, not a home](/a/mcp-as-a-projection).

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `{"error":"unknown_key"}` from `?key=…` | The row is disabled, deleted, or the key is misspelled | `GET /api/dispatch?ask=<intent>` and use `best.key`; never guess a neighbouring key |
| A brand-new row 404s for up to 30 seconds | `loadDirectory()` caches the directory snapshot in KV for 30 s (`dispatch.js:413-429`) | Wait it out, or the write path calls `invalidateDirSnapshot(env)` (`functions/api/directory/index.js:73`) |
| `tools/list` returns fewer tools than the table has rows | `planner_visible=0` and `enabled=0` rows are excluded (`mcp.js:118-123`) | Correct behaviour. Do not edit the count to match the table |
| `POST /api/mcp` → `-32001 unauthorized` | The MCP projection takes `Authorization: Bearer <MCP_TOKEN>` or `x-mcp-token`, not the terminal key (`mcp.js:24-27`) | Send the MCP token |
| A capability runs but `proof.ok` is false | The runner returned no material output | Read the receipt and fire a repair: `{"key":"…","body":"corrected","repairs":"inv_ID"}` |
| `?ask=` returns the right row buried below GitHub rows | Two-letter query terms match as substrings (`object_contract.js:605-640`) | Query with a distinctive noun, or add the row to the canonical pin list |


## Sources

1. Tool search tool — https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
2. Tool use with Claude — Pricing — https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
3. Tool search tool — context bloat and selection accuracy — https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
4. Model Context Protocol specification 2025-11-25 — Server Features: Tools — https://modelcontextprotocol.io/specification/2025-11-25/server/tools
5. Model Context Protocol specification — User Interaction Model — https://modelcontextprotocol.io/specification/2025-11-25/server/tools
6. modelcontextprotocol/modelcontextprotocol — docs/specification/2025-11-25/server/tools.mdx — https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-11-25/server/tools.mdx
7. scalekit-inc/mcp-vs-cli-benchmark — runnable harness — https://github.com/scalekit-inc/mcp-vs-cli-benchmark
8. MCP vs CLI: Benchmarking AI Agent Cost & Reliability — https://www.scalekit.com/blog/mcp-vs-cli-use
9. [Feature] Lazy-load MCP tool definitions to reduce token overhead — https://github.com/anomalyco/opencode/issues/35376
10. `GCORE_TOOLS=*` advertises ~488k tokens of tool definitions — https://github.com/G-Core/gcore-mcp-server/issues/14
11. Local models via opencode: slim or defer MCP tool and skills injection — https://github.com/nimbalyst/nimbalyst/issues/914
12. Full plugin suite's MCP tool-schema overhead makes small-context backends unusable — https://github.com/ruvnet/ruflo/issues/2726
13. Comment on: Apideck CLI — lower context consumption than MCP — https://news.ycombinator.com/item?id=47400262
14. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48330912
15. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48336021
16. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48331540
17. Comment on: Zero-Touch OAuth for MCP — https://news.ycombinator.com/item?id=48594160
18. Comment on: Claude Advanced Tool Use — https://news.ycombinator.com/item?id=46039648
19. ToolSearch does not index tools from claude.ai-hosted MCP servers — https://github.com/anthropics/claude-code/issues/57033
20. tool_search auto-gate computes its threshold from model.default — https://github.com/NousResearch/hermes-agent/issues/57520
21. Desktop: ~3.9k tokens of built-in MCP tool schemas load non-deferred — https://github.com/anthropics/claude-code/issues/76372
22. codex exec can silently complete empty when configured MCP tools are deferred — https://github.com/openai/codex/issues/24536
23. Kiro IDE does not handle MCP notifications/tools/list_changed — https://github.com/kirodotdev/Kiro/issues/6553
24. GitHub Copilot CLI does not dynamically load tools via tools/list_changed — https://github.com/microsoft/wassette/issues/308
25. Comment on: Agent Skills — https://news.ycombinator.com/item?id=46878126
26. Comment on: When does MCP make sense vs CLI? — https://news.ycombinator.com/item?id=47209810
27. Comment on: MCP is dead; long live MCP — https://news.ycombinator.com/item?id=47381282
28. First-party: the same catalogue exposed three ways — https://miscsubjects.com/api/dispatch?map=1
29. First-party: four counts of the same table, taken live — https://miscsubjects.com/api/dispatch?registry=1
30. First-party: weighing the MCP projection with tools/list — https://miscsubjects.com/api/mcp
31. First-party: adding, invoking and removing a capability with no deploy — https://miscsubjects.com/api/dispatch?confirm=inv_z77vqe1qi6
32. First-party: the resolver's twelve results for one query — https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it
33. redacted/miscsubjects-architecture — docs/tooling/directory-row.md — https://github.com/redacted/miscsubjects-architecture/blob/main/docs/tooling/directory-row.md

