# Coldread install

Coldread shows every agent that touches a product: crawlers and assistants on
the website, coding agents running its CLI or calling its MCP server.

Keys: https://coldread.apappas.dev/app (Overview, one site at a time), or Sign up below.
Everything is sent to POST https://coldread.apappas.dev/api/ingest by the
packages below. Never call it yourself.

- Website: `@coldread/track`. Secret key `cr_sec_...`, in env as `COLDREAD_KEY`.
- CLI: `@coldread/cli`. Public key `cr_pub_...`, in code.
- CLI in Go: `coldread.apappas.dev/go`. Public key `cr_pub_...`, in code.
- MCP server: `@coldread/cli/mcp`. Public key `cr_pub_...`, in code.
- Go website, API or MCP server: `coldread.apappas.dev/go`. Same keys.

## Sign up

No key yet? Ask the user for their email, then run:

```sh
npx -y @coldread/cli init --email <email> --site <site>
```

`--site`: for a website or API, the product's domain (`example.com`). The repo
doesn't name one (homepage, README, deploy config)? Ask the user. Never the
repo's or package's name: a name gets no secret, so the website can't send.
A CLI or MCP server with no website: its name.

Ask the user for the 6-digit code it emailed them, then run (CLI or MCP
server: add `--no-secret`):

```sh
npx -y @coldread/cli init --code <code>
```

Several sign-ups pending (other addresses): add `--email <email>` to say whose
code it is.

It prints the public key (`cr_pub_`), writes a secret key (`cr_sec_`) to
`.env` as `COLDREAD_KEY` (never shown) and starts a website's first read.
With `--no-secret`, the secret goes to `~/.config/coldread/keys.json`
instead, for Ask Coldread (below), never the repo.
Give the user the Dashboard link (one use), then follow your section below.
Errors say what to run next.

No Node (a Go, Python or other repo)? The same two steps with curl:

```sh
curl -sS https://coldread.apappas.dev/api/agent/signup/start -H 'content-type: application/json' \
  -d '{"email":"<email>","site":"<site>"}'
```

Keep the `id` it returns to yourself: not in files, not shown to the user.

```sh
curl -sS https://coldread.apappas.dev/api/agent/signup/verify -H 'content-type: application/json' \
  -d '{"id":"<id>","code":"<code>","site":"<site>","secret":false}'
```

Website or API: `"secret":true`, then write the response's `secret` straight
into `.env` as `COLDREAD_KEY` (`.env` in `.gitignore` first). Never repeat
it to the user. `publicKey` and `dashboard` are as above; `error` says what's
wrong.
CLI or MCP server, for Ask Coldread (below): add `"read":true` and keep the
response's `readKey` out of the repo (`printf %s <readKey> | npx -y @coldread/cli key set`).

## Rules

- `cr_sec_` is a secret. Env vars only: `.env` locally (check it is in
  `.gitignore` first; add it if not), the host's environment in production.
  Never in git, client code, logs, or a public variable (`NEXT_PUBLIC_*`,
  `VITE_*`, `PUBLIC_*`).
- `cr_pub_` is public and tied to one site. It ships in your code; that's fine.
- Keys don't cross over. `@coldread/cli`, `coldread.cli`, `coldread.mcp` and
  Go's `coldread.New` and `coldread.NewMCP` send nothing with a `cr_sec_` key.
  `@coldread/track`, the Python website middleware and Go's
  `coldread.NewTracker` with a `cr_pub_` key are refused and say so in the
  server log.
- Tracking never throws and never waits on Coldread. Don't await it, wrap it,
  or add retries.
- Under a test runner (Vitest, Jest, `node --test`, `NODE_ENV=test`; pytest,
  unittest, `manage.py test`, `PYTHON_ENV=test`; `go test`) the packages send
  nothing to their default endpoint, `https://coldread.apappas.dev/api/ingest`.
  Websites send test runs to any other endpoint. CLIs and MCP servers send
  nothing from a test run, whatever the endpoint (a suite runs the CLI
  hundreds of times), except with `COLDREAD_VERIFY=1` to an endpoint that
  isn't production. Run your tests as usual (they're muted); verify by running
  the real CLI, server or site, not from the test suite.
- Trying the CLI or MCP server out by hand or with an agent: set
  `COLDREAD_TEST=1`. Those runs are kept apart from real traffic (the
  dashboard's Test view), never billed, and deleted after 7 days.
  Leave `COLDREAD_ENDPOINT` unset.
- Unsupported stack (listed per section): a website first gets checked for
  its host: on Cloudflare, Vercel or Netlify, any stack works (Cloudflare (any
  site), Vercel (any site), Netlify (any site)). On none of them, or anything
  else unsupported: see Not supported below. Don't improvise an install.

## Not supported

1. No key yet: Sign up anyway (above). A website's cold read starts with it.
2. Tell Coldread what was missing (`surface`: website, api, cli or mcp;
   `stack`: the language, framework or host, a few words):

```sh
npx -y @coldread/cli unsupported --surface api --stack rails --key <cr_pub_...>
```

   No `--key`: it uses `COLDREAD_KEY` from `.env`. No key at all (signup
   couldn't finish): `--site <domain or tool name>` instead (curl: no
   `authorization`, `"site"` in the body). No Node:

```sh
curl -sS https://coldread.apappas.dev/api/agent/unsupported -H 'content-type: application/json' \
  -H 'authorization: Bearer <cr_pub_...>' -d '{"surface":"api","stack":"rails"}'
```

3. Tell the user in one line what isn't supported yet, and that the cold read
   (a website) or the dashboard is ready: the Dashboard link.

No network at all: tell the user. Nothing here works without it.

## Website: @coldread/track

Server-side on purpose: AI crawlers don't run JavaScript, so there is no
browser snippet. Node 18+, edge runtimes, Cloudflare Workers. ESM and CommonJS.
Any other stack on Cloudflare, Vercel or Netlify: Cloudflare (any site),
Vercel (any site), Netlify (any site).

```sh
npm i @coldread/track
```

`.env`

```
COLDREAD_KEY=cr_sec_...
```

Set the same variable in the host's environment. `.env` changes need a server
restart.

Next.js loads `.env` itself. A plain Node server that doesn't yet (no dotenv):
`node --env-file-if-exists=.env` (Node 22.9+; tsx takes it too). Never
`--env-file=.env` in a start script: it exits where there's no `.env`, as in
production.

### Next.js

`middleware.ts`, beside `app/` or `pages/` (in `src/` if the project uses it):

```ts
import { NextResponse, type NextRequest, type NextFetchEvent } from "next/server";
import { withColdread } from "@coldread/track/next";

export function middleware(req: NextRequest, event: NextFetchEvent) {
  withColdread(req, { event });
  return NextResponse.next();
}

export const config = { matcher: ["/((?!_next/static|_next/image).*)"] };
```

- Middleware already exists: add `withColdread(req, { event });` as its first
  line, and give the function the `event` argument. Keep the rest. Its matcher
  must still reach pages, `/robots.txt`, `/llms.txt` and `/sitemap.xml`.
- Next.js 16+: the file is `proxy.ts` and the function is `proxy`. Same body.
  (`middleware.ts` still works there, with a deprecation warning.)
- The visitor's IP: Vercel's `x-real-ip` on Vercel, `cf-connecting-ip` on
  Cloudflare Workers. Elsewhere none, since any visitor can write those
  headers (a bot is then claimed, not verified), unless proxies you control
  sit in front and add to `X-Forwarded-For`: `COLDREAD_TRUST_PROXY=1` (how
  many). Host puts it elsewhere:
  `withColdread(req, { event, ip: (r) => r.headers.get("fly-client-ip") })`

### Express

```ts
import express from "express";
import { clientIp, createClient } from "@coldread/track";

const coldread = createClient(); // COLDREAD_KEY from env
const app = express();

app.use((req, res, next) => {
  let done = false;
  // Once the response is sent, with its status; or when the client gives up
  // first, with the status if it was sent.
  const track = () => {
    if (done) return;
    done = true;
    coldread.track({ method: req.method, url: req.originalUrl, headers: req.headers, ip: clientIp(req.headers, req.ip), host: req.headers.host, status: res.headersSent ? res.statusCode : null });
  };
  res.on("finish", track);
  res.on("close", track);
  next();
});
```

Register it before your routes. It reports each request when its response
finishes, with the status, so agents' missing pages and sign-in walls show up.
`req.ip` is the connection's address (or what the app's own `trust proxy`
says). Behind proxies you control that add to `X-Forwarded-For` and no
`trust proxy`: `COLDREAD_TRUST_PROXY=1` (how many) in the environment; don't
change `trust proxy` for this.

Shutdown: if the app already stops gracefully (`server.close()` on SIGTERM),
add `await coldread.flush()` after it. If it doesn't, don't add a signal
handler: a restart then loses at most the last 2 seconds and the current
minute's people counts.

### Cloudflare Workers

```ts
import { createClient, type Client } from "@coldread/track";

let coldread: Client | undefined;

export default {
  async fetch(req: Request, env: { COLDREAD_KEY: string }, ctx: ExecutionContext) {
    coldread ??= createClient({ key: env.COLDREAD_KEY });
    coldread.track({
      method: req.method,
      url: req.url,
      headers: req.headers,
      ip: req.headers.get("cf-connecting-ip"),
      host: new URL(req.url).host,
    }, (p) => ctx.waitUntil(p));
    return handle(req, env, ctx); // your existing handler
  },
};
```

Key: `.dev.vars` locally, not `.env` (check it is gitignored);
`npx wrangler secret put COLDREAD_KEY` in production. Workers need `@coldread/track`
0.1.1 or later (0.1.0 sends nothing there).

### Any other JavaScript server

Node, Bun, Deno, Fastify, Hono, Koa: one client per process, one `track()` per
request.

```ts
import { createClient } from "@coldread/track";

const coldread = createClient();

// every request, once its response is sent (status: 200, 404, ...)
coldread.track({ method, url, headers, ip, host, status });

// serverless: pass waitUntil so the send outlives the response
coldread.track({ method, url, headers, ip, host, status }, (p) => ctx.waitUntil(p));

// a process that exits on its own
await coldread.flush();
```

`url` can be a path or a full URL. `headers` is a `Headers` object or a plain
object. `status` is the response's: track once it's known (after the handler,
or in the server's response-finished hook). Without it the request still
counts, but missing pages and sign-in walls can't be told apart. `ip` is the
connection's address, or `clientIp(headers, peer)` from the package (it reads
`COLDREAD_TRUST_PROXY`; never a raw `cf-connecting-ip` or `x-forwarded-for`,
which visitors can forge). `createClient()` reads `COLDREAD_KEY` when it's called, so load env
first. No `process.env` (Deno without it): pass `createClient({ key })`.
On the server's own shutdown path, `await coldread.flush()`.

Python: see "Python". Go: see "Website or API (Go)" below.
Ruby, PHP or a static host:
Cloudflare (any site), Vercel (any site) or Netlify (any site) below, if the
site is on one of them. If it isn't: see Not supported above.

### Cloudflare (any site)

For a site Cloudflare proxies (orange cloud) with no JavaScript, Python or Go
server to add a package to: PHP, Ruby, WordPress, Webflow, a static host.
A Worker on the site's routes passes every request to the origin untouched and
reports it. Nothing changes in the site's code or its repo.

Is it on Cloudflare (the site's domain for `example.com`):

```sh
curl -sI https://example.com | grep -i '^cf-ray'
dig +short NS example.com
```

- `cf-ray` and nameservers ending in `ns.cloudflare.com`: yes. The zone must be
  in the user's Cloudflare account.
- No `cf-ray`: not on Cloudflare. On Vercel or Netlify: their sections below.
  None of them: see Not supported above.
- `cf-ray` with other nameservers: usually the host's Cloudflare (a site
  builder's), not the user's. Ask the user.
- The site is a Worker already: use Cloudflare Workers above instead.

A new folder, outside the site's repo:

```sh
mkdir coldread-worker && cd coldread-worker
npm init -y
npm i @coldread/track
npm i -D wrangler
```

`wrangler.toml`, the site's domain for `example.com`. One route per hostname;
drop `www` if the site has none:

```toml
name = "coldread"
main = "src/index.ts"
compatibility_date = "2025-09-01"
workers_dev = false
preview_urls = false

routes = [
  { pattern = "example.com/*", zone_name = "example.com" },
  { pattern = "www.example.com/*", zone_name = "example.com" },
]
```

`src/index.ts`:

```ts
import { createClient, type Client, type RequestFacts } from "@coldread/track";

interface Env { COLDREAD_KEY?: string }
interface Ctx { waitUntil(p: Promise<unknown>): void; passThroughOnException(): void }

let coldread: Client | undefined;

export default {
  fetch(request: Request, env: Env, ctx: Ctx): Promise<Response> {
    // If this Worker ever throws, Cloudflare sends the request on as if it weren't here.
    ctx.passThroughOnException();
    const client = (coldread ??= createClient({ key: env.COLDREAD_KEY }));
    const facts: RequestFacts = {
      method: request.method,
      url: request.url,
      headers: request.headers,
      ip: request.headers.get("cf-connecting-ip"),
      host: new URL(request.url).host,
    };
    // Same URL, method, headers and body; the origin's response, streamed back
    // as is. It's reported once its status is known, before the body streams.
    return fetch(request).then(
      (response) => { client.track({ ...facts, status: response.status }, (p) => ctx.waitUntil(p)); return response; },
      (error) => { client.track(facts, (p) => ctx.waitUntil(p)); throw error; },
    );
  },
};
```

```sh
npx wrangler login
npx wrangler deploy
npx wrangler secret put COLDREAD_KEY
```

- `wrangler login` opens a browser: the user signs in once.
- `secret put` asks for the key (piped input works) and applies at once. Never
  in `wrangler.toml` or git. Until it's set, requests pass through and nothing
  is sent.
- The IP comes from `cf-connecting-ip`, which Cloudflare sets and visitors
  can't forge, so bots claiming to be GPTBot, Googlebot and the rest are
  checked against their companies' published ranges.
- Deploy refuses the routes: the zone isn't in this account, or another Worker
  has them (add `track()` to that one, as in Cloudflare Workers above).
- Cloudflare's free Workers plan covers 100,000 requests a day, every request
  on the routes included. Past it, the zone's Workers Routes request limit
  failure mode decides: set it to Fail open, so requests skip the Worker
  instead of failing. Busier sites: a paid Workers plan.
- Undo: `npx wrangler delete`. The site is served as before.

Verify:

1. `npx wrangler tail` in one terminal, then load a page
   (`curl -sI https://example.com/`: same status as before).
2. The tail says `[coldread] connected: first requests accepted`, once per Worker
   instance, a few seconds after the first request. That's the signal.
   `[coldread] no key (set COLDREAD_KEY); not sending.`: the secret isn't set.
3. The Coldread dashboard also shows it: https://coldread.apappas.dev/app turns live by itself.

### Vercel (any site)

For a site Vercel hosts with no JavaScript or Python server to add a package
to: static HTML, a Vite or React SPA, Hugo, Jekyll, Eleventy. Vercel's Routing
Middleware runs before each request, reports it, and lets it through. Not for
Next.js (its section above) or Astro.

Is it on Vercel (the site's domain for `example.com`):

```sh
curl -sI https://example.com | grep -i '^x-vercel-id'
```

- `x-vercel-id`: yes. The project must be one the user can deploy.
- No `x-vercel-id`: not on Vercel. On Netlify: Netlify (any site) below.
  Otherwise: see Not supported above.

In the directory Vercel builds from (its Root Directory). Already has a
`package.json`: run only the last line, and add `"type": "module"` to it if
it has no `"type"`. A `"type": "commonjs"`: name the file `middleware.mjs`.

```sh
npm init -y
npm pkg set type=module
npm i @coldread/track
```

`middleware.js`, beside `package.json`:

```js
import { createClient } from "@coldread/track";

const coldread = createClient(); // COLDREAD_KEY from the project's environment

// Runs before every matched request and returns nothing, so the request goes
// on to the site untouched. The report is sent in the background.
export default function middleware(request, context) {
  coldread.track({
    method: request.method,
    url: request.url,
    headers: request.headers,
    ip: request.headers.get("x-real-ip"),
    host: new URL(request.url).host,
  }, (p) => context.waitUntil(p));
}

// Pages, robots.txt, llms.txt and sitemap.xml. Not images, fonts, scripts or styles.
export const config = {
  matcher: ["/((?!.*\\.(?:css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|mp4|webm)$).*)"],
};
```

Commit `package.json`, `package-lock.json` and `middleware.js`. Never
`node_modules` (add it to `.gitignore`).

The key, in the project's environment (each asks for the value; piped input
works), or in the dashboard (Settings, Environment Variables):

```sh
npx vercel env add COLDREAD_KEY production
npx vercel env add COLDREAD_KEY preview
```

Then deploy as the project does (a push, or `npx vercel --prod`). Environment
variables apply from the next deploy.

- The IP comes from `x-real-ip`, which Vercel sets and visitors can't forge,
  so bots claiming to be GPTBot, Googlebot and the rest are checked against
  their companies' published ranges.
- Middleware can't see the response, so no status is sent.
- Each matched request is a middleware invocation in the project's Vercel
  usage. The matcher skips assets to keep that down.
- Undo: delete `middleware.js` and deploy.

Verify:

1. Load a page on the deployment (a preview is fine): same page as before.
2. The project's Logs (Vercel dashboard) say `[coldread] connected: first requests accepted`,
   a few seconds after the first request. That's the signal.
   `[coldread] no key (set COLDREAD_KEY); not sending.`: the variable isn't
   set for that environment, or the deploy predates it.
3. The Coldread dashboard also shows it: https://coldread.apappas.dev/app turns live by itself.

### Netlify (any site)

For a site Netlify hosts with no JavaScript or Python server to add a package
to: docs (Docusaurus, Hugo, Astro, MkDocs), static HTML, an SPA. A Netlify edge
function wraps every page: Netlify serves it as before, and the function
reports it with its status.

Is it on Netlify (the site's domain for `example.com`):

```sh
curl -sI https://example.com | grep -i '^x-nf-request-id'
```

- `x-nf-request-id`: yes. The site must be one the user can deploy.
- No `x-nf-request-id`: not on Netlify. See Not supported above.

In the site's base directory (where Netlify builds from). Already has a
`package.json`: run only the last line.

```sh
npm init -y
npm i @coldread/track
```

`netlify/edge-functions/coldread.ts` (or the `edge_functions` directory
`netlify.toml` names, if it sets one):

```ts
import { createClient, type Client, type RequestFacts } from "@coldread/track";

// Netlify's runtime global and the context it hands an edge function, as used here.
declare const Netlify: { env: { get(name: string): string | undefined } };
interface Context { ip: string; next(): Promise<Response>; waitUntil(p: Promise<unknown>): void }

let coldread: Client | undefined;

export default async (request: Request, context: Context): Promise<Response> => {
  const facts: RequestFacts = {
    method: request.method,
    url: request.url,
    headers: request.headers,
    ip: context.ip,
    host: new URL(request.url).host,
  };
  // The page as Netlify serves it, streamed back as is. It's reported once
  // its status is known, before the body streams.
  const response = await context.next();
  const client = (coldread ??= createClient({ key: Netlify.env.get("COLDREAD_KEY") }));
  client.track({ ...facts, status: response.status }, (p) => context.waitUntil(p));
  return response;
};

export const config = {
  path: "/*",
  // Pages, robots.txt, llms.txt and sitemap.xml. Not images, fonts, scripts or styles.
  excludedPath: ["/*.css", "/*.js", "/*.mjs", "/*.map", "/*.png", "/*.jpg", "/*.jpeg", "/*.gif", "/*.webp", "/*.avif", "/*.svg", "/*.ico", "/*.woff", "/*.woff2", "/*.ttf", "/*.mp4", "/*.webm"],
  // If this function ever fails, Netlify serves the page as if it weren't here.
  onError: "bypass",
};
```

Commit `package.json`, the lockfile and the function. Never `node_modules`.

The key, in the site's environment: Site configuration, Environment variables
(all scopes, or Functions), or with the key in the shell's `COLDREAD_KEY`:

```sh
npx netlify env:set COLDREAD_KEY "$COLDREAD_KEY"
```

Then deploy as the site does (a push). Environment variables apply from the
next deploy.

- `onError: "bypass"`: if the function ever fails, Netlify serves the page
  as if it weren't there.
- The IP is `context.ip`, which Netlify sets and visitors can't forge, so bots
  claiming to be GPTBot, Googlebot and the rest are checked against their
  companies' published ranges.
- Each matched request is an edge function invocation in the site's Netlify
  usage. The excluded paths skip assets to keep that down.
- Undo: delete the function and deploy.

Verify:

1. Load a page on the deploy (a deploy preview is fine): same page as before.
2. The site's edge function logs (Netlify: Logs, Edge Functions) say
   `[coldread] connected: first requests accepted`, a few seconds after the first
   request. That's the signal.
   `[coldread] no key (set COLDREAD_KEY); not sending.`: the variable isn't
   set, or the deploy predates it.
3. The Coldread dashboard also shows it: https://coldread.apappas.dev/app turns live by itself.

### Sent

Method, path (no query string), the response status where the code sees it,
client IP, host, and `user-agent`, `accept`, `accept-language`,
`sec-fetch-dest`, `sec-ch-ua`, `sec-ch-ua-platform`, `sec-ch-ua-mobile`,
`signature`, `signature-input`, `signature-agent`, `ai-agent`. The IP verifies bots, then is discarded. Never cookies, auth, other
headers or bodies. People's page views go as counts per path and minute, with
no IP or headers (`people: "full"` sends them whole).

### Verify

1. Deploy (a preview is fine) and load any page. Or build and start in
   production mode locally and request a page.
2. The server log says `[coldread] connected: first requests accepted`, once per
   process, when Coldread has accepted the first batch. That's the signal.
3. The Coldread dashboard also shows it: https://coldread.apappas.dev/app turns live by itself.

- `next dev`: a lone request can wait for the next one before it's sent.
- Log says `[coldread] no key (set COLDREAD_KEY); not sending.`: the variable
  isn't set where the server runs.
- Log says `[coldread] key rejected; paused 10 minutes.`: wrong or revoked key.
  Rotating: deploy the new key before revoking the old one.
- `[coldread] blocked before reaching Coldread (HTTP 403, ...)`: a firewall
  between the server and Coldread, not the key.
- `[coldread] dropped N records (...)`: Coldread asked it to slow down (429)
  or failed (5xx), or the queue filled; it resumes by itself.

## Python

`coldread-sdk` (website, CLI or MCP server) isn't on PyPI yet, so there is
nothing to install. Follow Not supported above (`--stack "flask, no
package"`, `--stack "click, no package"`). Never `pip install coldread`: that
is someone else's package.

## Website or API (Go): coldread.apappas.dev/go

Go 1.21+, no dependencies. `net/http` and anything on it: `http.ServeMux`,
chi, gin, echo, gorilla/mux.

```sh
go get coldread.apappas.dev/go
```

`COLDREAD_KEY` (and `COLDREAD_ENDPOINT` when set) in `.env` and the host's
environment, as above. Go doesn't read `.env`: export them in the shell that
starts the server. Never put the key in code.

```go
import (
	"net/http"

	"coldread.apappas.dev/go"
)

func main() {
	tr := coldread.NewTracker(coldread.TrackerOptions{}) // COLDREAD_KEY from env

	http.ListenAndServe(":8080", tr.Middleware(mux)) // mux: your router
}
```

- chi: `r.Use(tr.Middleware)`. gin, echo, gorilla/mux: wrap the router where
  the server is made, `tr.Middleware(router)`.
- Shutdown: if the server already stops gracefully, call `tr.Close()` once
  `srv.Shutdown(ctx)` returns (not after `ListenAndServe`, which returns
  as soon as Shutdown starts), so nothing queued is lost:

```go
// Where the server already stops gracefully:
srv.Shutdown(ctx) // waits for requests in flight
tr.Close()        // then sends what's queued
```

  No graceful stop: don't add one for this. A kill loses at most the last 2
  seconds and the minute's people counts.
- Not on `net/http` (Fiber, fasthttp): `tr.Track(coldread.RequestFacts{...})`
  once each response is sent, with its method, URL, header, IP, host and status.
- The visitor's IP is the connection's (Vercel's `x-real-ip` on Vercel):
  headers are anyone's to write. Behind proxies you control that add to
  `X-Forwarded-For`: `COLDREAD_TRUST_PROXY=1` (how many), or
  `TrackerOptions{TrustProxy: 1}`. Host puts it elsewhere:
  `coldread.TrackerOptions{IP: func(r *http.Request) string { return r.Header.Get("Fly-Client-Ip") }}`
- Sent: exactly what the JavaScript package sends (above), with the status.

### Verify

1. Start the server as in production (`go run .` is fine, not under `go test`)
   and request a page.
2. Its stderr says `[coldread] connected: first requests accepted`, once per
   process, when Coldread has accepted the first batch. That's the signal.
3. The Coldread dashboard also shows it: https://coldread.apappas.dev/app turns live by itself.

- `[coldread] no key (set COLDREAD_KEY); not sending.`: the variable isn't
  set where the server runs.

## CLI: @coldread/cli

Node 18+ or Bun, run as `node` or `bun`. Python: see "Python" above.
Go: see "CLI in Go" below. Not yet: single-file binaries (Node SEA, `bun build
--compile`), Deno, and CLIs in other languages (Rust). They send nothing: see
Not supported above.

```sh
npm i @coldread/cli
```

Track from the parser: the command it matched and the names of the options
set on the command line. Never argv: it holds paths, URLs, emails and keys.

### commander

```ts
import { Command } from "commander";
import { coldread } from "@coldread/cli";

const cr = coldread({ key: "cr_pub_...", tool: "acme", version: "1.2.0", optOut: "ACME_NO_TELEMETRY" });
const program = new Command("acme");

// The command commander matched and the options set on the command line:
// names from commander, never argv, never values.
program.hook("preAction", (_root, cmd) => {
  const path: string[] = [];
  const flags: string[] = [];
  for (let c: Command | null = cmd; c; c = c.parent) {
    if (c.parent) path.unshift(c.name());
    for (const o of c.options) if (c.getOptionValueSource(o.attributeName()) === "cli") flags.push(o.long ?? o.short ?? "");
  }
  cr.track({ command: path.join(" "), flags });
});

// A command line commander refused (unknown command or option, a missing
// argument): the command tried, recorded with the code the process exits
// with (commander's 1). Its path is walked through
// commander's own commands and options (an option's value is never a
// command); an unknown command is that word under it ("db nosuch").
// Before adding commands.
const tried = (unknownCommand: boolean) => {
  const path: string[] = [];
  let cmd: Command = program;
  const args = process.argv.slice(2);
  for (let i = 0; i < args.length; i++) {
    const a = args[i];
    if (a === "--") break;
    if (a.startsWith("-")) {
      const name = a.split("=")[0];
      let o;
      for (let c: Command | null = cmd; c && !o; c = c.parent) o = c.options.find((x) => x.long === name || x.short === name);
      if (o && (o.required || o.optional) && !a.includes("=")) i++;
      continue;
    }
    const sub = cmd.commands.find((c) => c.name() === a || c.aliases().includes(a));
    if (!sub) { if (unknownCommand) path.push(a); break; }
    path.push(sub.name());
    cmd = sub;
  }
  return path.join(" ");
};
program.exitOverride((err) => {
  if (["commander.helpDisplayed", "commander.help", "commander.version"].includes(err.code)) return;
  cr.parseError({ command: tried(err.code === "commander.unknownCommand") });
});

// your commands, then program.parse() as before
```

commander 8.3+ (`getOptionValueSource`). Older: the options set are the
`process.argv` words matching an option's `long` or `short`, up to `--`.

### yargs

```ts
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { coldread } from "@coldread/cli";

const cr = coldread({ key: "cr_pub_...", tool: "acme", version: "1.2.0", optOut: "ACME_NO_TELEMETRY" });
const raw = hideBin(process.argv);
// The options set on the command line, by the names yargs defines, never
// their values: --dry-run however it was typed (--dryRun), --no-x as typed.
const kebab = (k: string) => k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
const given = (args: Record<string, unknown>) => [...new Set(raw.flatMap((a) => {
  const m = /^(--?)([^=]+)/.exec(a);
  if (!m || a === "--") return [];
  const name = m[2];
  if (m[1] === "-") return name.length === 1 && name in args ? [`-${name}`] : [];
  if (name in args) return [`--${kebab(name)}`];
  return name.startsWith("no-") && name.slice(3) in args ? [`--no-${kebab(name.slice(3))}`] : [];
}))];

yargs(raw)
  .command("deploy <dir>", "Deploy", (y) => y.option("prod", { type: "boolean" }), (args) => {
    cr.track({ command: "deploy", flags: given(args) }); // the command this handler is
    // ...
  })
  .strict()
  // yargs' own parse, refused or not: argv._ holds the words it read, never
  // an option's value. Refused: the command tried (its words up to the
  // first one yargs didn't know: "db migrate", "db nosuch"), recorded with
  // the code the process exits with. Then what yargs does by default: help
  // and the message, exit 1.
  .parse(raw, {}, (err: Error | undefined, argv: { _: (string | number)[] }, output: string) => {
    if (err && err.name !== "YError") throw err; // the command's own error, not the parser's
    if (err) {
      const unknown = new Set(/^Unknown arguments?: (.*)$/.exec(err.message)?.[1].split(", ") ?? []);
      const words = argv._.map(String);
      const cut = words.findIndex((w) => unknown.has(w));
      cr.parseError({ command: (cut < 0 ? words : words.slice(0, cut + 1)).join(" ") });
    }
    if (output) (err ? console.error : console.log)(output);
    if (err) process.exit(1);
  });
```

Keep `.strict()` (or `.demandCommand()`): without it yargs refuses nothing,
so there is nothing to record.

### Any other parser

```ts
import { coldread } from "@coldread/cli";

const cr = coldread({ key: "cr_pub_...", tool: "acme", version: "1.2.0", optOut: "ACME_NO_TELEMETRY" });

// once you know the command
cr.track({ command: "deploy preview", flags: ["--prod"] });
```

Call `track()` once the parser has matched a command, with its path and the
option names set on the command line. A command line the parser refused (an
unknown command or option, a missing argument, a value that didn't
validate): `cr.parseError({ command })` with the command tried, recorded with
the code the process exits with (2 only if it would exit 0).

CommonJS: `const { coldread } = require("@coldread/cli")`

- `key`: the site's `cr_pub_` key, as a literal.
- `tool`: the name users type. `version`: the CLI's own version: the
  variable its `--version` prints (`package.json`'s), never a placeholder.
- `optOut`: an env var your users set to opt out. `DO_NOT_TRACK=1` and
  `COLDREAD_DISABLED=1` always work.
- Call `coldread()` once at startup and `track()` once the parser has matched a
  command. Pass the command path (`deploy preview`), never argv; the root
  command (no subcommand) too, as `""` (sent as the tool's name). Flags:
  names only; values are stripped.
- What's kept: up to 4 plain words of the command, cut at the first that
  isn't one; flag names `--long-name` or `-x`. Paths, URLs, emails, keys and
  values are dropped, here and again by Coldread. `COLDREAD_DEBUG=1` says what
  was dropped.
- Exit as usual; the exit code is sent for you. Or `cr.finish({ exit })`.
- A failed run also sends its arguments and its last stderr line, cleaned;
  `errorDetail: false` turns that off. Leave it on: secrets, emails, paths,
  ids and IPs are already removed (a path keeps its file name). Turn it off
  only when arguments are someone's own words (a prompt, a message).
- A non-zero exit that's only ever a normal result (issues found, a diff,
  nothing to do): if the repo documents it (README, docs, help text, or the
  code that sets it), declare it, citing where in a comment:
  `expected: { detect: [1] }` (command path to exit codes; the root as
  `""`). Those runs never count as failures. Not documented, or the same
  code also means an error (a bad config, a crash): leave it out; the owner
  can mark it "Ignore this error".
- A run killed by a signal (SIGTERM, SIGHUP, SIGQUIT: an agent's timeout) is
  not recorded. Coldread adds no signal handler, so a kill does exactly what
  it did before. To record those runs as exit 128+n, opt in with
  `captureSignals: true` (Python: `capture_signals=True`), or `ownSignals` /
  `own_signals` when the CLI handles them itself. Ctrl-C sends nothing.
- `--help`, `help` and the root's `--version` aren't uses: nothing is sent.
- A shell hook that runs the CLI on every prompt (`acme env` from `acme
  activate`): don't call `track()` for those runs (skip it when your hook's
  flag or variable is set). Each would be a send, and they'd fill Commands.
- No network, or Coldread didn't answer: events wait on disk (the newest 100
  within 64 KB, a week at most; past that the oldest go) and go with a later
  run.
- The CLI's own API calls: spread `cr.headers()` into their headers. Do it if
  the CLI calls an API you also track: without it, each agent run shows twice,
  once as the agent's CLI run and again as an "Unknown" API request.
- `cr.agent` is the agent running the CLI, or null. Use it to skip prompts an
  agent can't answer.
- `inferParent: true` also checks parent processes (Copilot CLI, Aider,
  Windsurf). It runs `ps` on macOS. Off by default.
- A one-line notice shows once, on stderr, only at a person's terminal.
  `notice: false` hides it; a string replaces it.

Add to the README:

```md
## Telemetry

Acme sends anonymous usage data to its developers through Coldread: which
command ran, its flag names, exit code and duration,
whether an AI coding agent ran it and which one, and your OS and architecture.
A failed run also sends its arguments and error message, with secrets,
emails, file paths and IP addresses removed. Never your hostname or
username. Your IP address is not stored.

To opt out, set `ACME_NO_TELEMETRY=1` or `DO_NOT_TRACK=1`; to send no
arguments or error messages, `COLDREAD_ERROR_DETAIL=off`.
```

### Verify

1. `COLDREAD_VERIFY=1 acme deploy preview`, then the bare `COLDREAD_VERIFY=1
   acme` (the root command; skip it if it starts something long-running; if
   it only prints help, `nothing sent` is right: help isn't a use),
   outside a test runner: each sends its event for real, waits for the answer,
   and prints `[coldread] verify: accepted` on stderr. That's the signal. Anything else
   says why: `rejected (...)`, `not sending (...)`, `nothing sent (...)`, or
   `no network here` in a sandbox without network (Codex's default): the
   event waits on disk (the line says where; the temp folder when the
   sandbox blocks the cache) and goes with the next run that has network.
   `codex exec`'s default sandbox is read-only: the line says `not saved`
   and the event is lost; `--sandbox workspace-write` keeps it. With
   `expected` set, a second line says `expected exits accepted (...)`.
2. The Coldread dashboard also shows it: https://coldread.apappas.dev/app turns live. Verify runs
   are marked and count nowhere (not in uses or failures), so
   real runs are what fill it.

`COLDREAD_DEBUG=1` prints the event to stderr and sends nothing: a dry run.
Neither prints anything unless set.

## CLI in Go: coldread.apappas.dev/go

Go 1.21+. One helper per parser; the module itself has no dependencies.

### cobra

```sh
go get coldread.apappas.dev/go/crcobra
```

```go
import (
	"coldread.apappas.dev/go"
	"coldread.apappas.dev/go/crcobra"
)

func main() {
	cr := coldread.New(coldread.Options{Key: "cr_pub_...", Tool: "acme", Version: "1.2.0", OptOut: "ACME_NO_TELEMETRY"})
	crcobra.Execute(cr, rootCmd) // replaces rootCmd.Execute()
}
```

It tracks the command cobra ran (`deploy preview`; the root command as the
tool's name) with the flags set on it, then exits: 0, or 1 on an error. A
command line cobra refused is the command tried, recorded with the 1 the
process exits with (an unknown command as its own word). A panic is
recorded as Go's 2, then the panic as before.

- Exit codes of your own: `crcobra.Execute` exits 0 or 1. Keep yours by
  running cobra yourself, then `code := crcobra.Report(cr, cmd, err)` and
  `cr.Exit` with your code (as below, for a wrapped cobra).
- A command that exits inside its Run (`cr.Exit(code)`) ends before
  `Execute` sees its flags: call `crcobra.Track(cr, cmd)` first in that Run,
  or once in the root's `PersistentPreRun` (cobra has parsed the flags by
  then).

### A framework that wraps cobra, or your own executor

bep/simplecobra, or a main that runs cobra its own way: after it runs, give
`crcobra.Report` the `*cobra.Command` that ran and the error. Never cut the
root's name off the path yourself (`strings.Cut` leaves the root command
`""`): it names every command, the root included, and tells a refused command
line from a failed command. (`crcobra.Track(cr, cmd)` is the tracking alone.)

```go
cr := coldread.New(coldread.Options{Key: "cr_pub_...", Tool: "acme", Version: "1.2.0", OptOut: "ACME_NO_TELEMETRY"})
defer cr.Recover() // a panic: recorded (exit 2), then raised again
cd, err := x.Execute(ctx, args) // bep/simplecobra; your own executor: the command it ran
var cmd *cobra.Command
if cd != nil {
	cmd = cd.CobraCommand
}
// The command (the root too, as the tool's name), or the command line
// cobra refused (an unknown command as its own word): 0, or 1.
code := crcobra.Report(cr, cmd, err)
cr.Exit(code) // in place of os.Exit; cr.Finish(code) when main returns
```

### urfave/cli v3

```sh
go get coldread.apappas.dev/go/crurfave
```

```go
import (
	"context"
	"os"

	"coldread.apappas.dev/go"
	"coldread.apappas.dev/go/crurfave"
)

func main() {
	cr := coldread.New(coldread.Options{Key: "cr_pub_...", Tool: "acme", Version: "1.2.0", OptOut: "ACME_NO_TELEMETRY"})
	crurfave.Execute(context.Background(), cr, cmd, os.Args) // replaces cmd.Run(ctx, os.Args)
}
```

It exits with the error's code (`cli.Exit`), else 1 after printing it. Bad
flags, unknown commands and missing required flags are the command tried,
recorded with the code the process exits with; a panic is recorded as Go's
2, then the panic. v2: no helper; call
`cr.Track("deploy preview", "--prod")` in the command's Action and exit with
`cr.Exit(code)`.

### flag, or any other parser

```sh
go get coldread.apappas.dev/go
```

```go
import (
	"errors"
	"flag"
	"os"
	"slices"

	"coldread.apappas.dev/go"
)

// Your commands. The one that runs comes from this list, never from argv's
// other words: positionals are arguments (paths, URLs, keys).
var commands = []string{"up", "down", "status", "create"}

func main() {
	cr := coldread.New(coldread.Options{Key: "cr_pub_...", Tool: "acme", Version: "1.2.0", OptOut: "ACME_NO_TELEMETRY"})
	defer cr.Recover() // a panic: recorded (exit 2), then raised again
	fs := flag.NewFlagSet("acme", flag.ContinueOnError)
	// ...your flags, on fs
	if err := fs.Parse(os.Args[1:]); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			os.Exit(0) // -h: nothing sent
		}
		cr.ParseError("") // a flag it didn't know; exits 2, as ExitOnError does
		cr.Exit(2)
	}
	command := ""
	for _, a := range fs.Args() {
		if slices.Contains(commands, a) {
			command = a
			break
		}
	}
	if command == "" && fs.NArg() > 0 {
		cr.ParseError(fs.Arg(0)) // a command it doesn't have
		cr.Exit(2)
	}
	cr.TrackFlags(command, fs) // the command, and the flags that were set
	code := run(command, fs.Args())
	cr.Exit(code) // replaces os.Exit(code)
}
```

`flag.ContinueOnError`, so a bad flag is recorded before the CLI exits. Any
other parser: `cr.Track("deploy preview", "--prod")` once it has matched a
command. The command path from your parser, never argv's words;
the root command as `cr.Track("")` (sent as the tool's name). Flags: names
only. A refused command line: `cr.ParseError("the command tried")`.

- `Key`: the site's `cr_pub_` key, as a literal. `Tool`, `OptOut`: as for
  `@coldread/cli`. `Version`: the variable the CLI's `--version` prints
  (often set with `-ldflags`), never a placeholder like `"dev"`.
- `os.Exit` skips the send: exit with `cr.Exit(code)` instead, or call
  `cr.Finish(code)` before main returns. Every `os.Exit`, in commands too
  (a scan's "found something" exit): one left sends nothing for that run. A
  command that calls `log.Fatal` or `cobra.CheckErr` itself sends nothing:
  return an error.
- `defer cr.Recover()` first in main: a panic is recorded (exit 2), then
  raised again. crcobra and crurfave do it for you.
- A failed run also sends its arguments and the error the CLI printed, cleaned:
  pass it with `cr.ErrorMessage(err.Error())` (crcobra and crurfave do it).
  `NoErrorDetail: true` turns that off. Leave it on: secrets, emails, paths,
  ids and IPs are already removed (a path keeps its file name). Turn it off
  only when arguments are someone's own words (a prompt, a message).
- A documented non-zero exit that's only ever a normal result: as for
  `@coldread/cli`, `Expected: map[string][]int{"detect": {1}}` (the root as
  `""`), citing where in a comment. Not documented: leave it out.
- Killed runs: as for `@coldread/cli`, not recorded unless you opt in.
  `CaptureSignals: true` for a CLI with no signal handling of its own;
  `OwnSignals: true` for one that handles them itself (`signal.Notify`,
  `signal.NotifyContext`), never without a handler (the signal would no longer
  end it).
- `--help`, `help` and the root's `--version` aren't sent. Shell hooks: as
  for `@coldread/cli`.
- At exit the send waits 300 ms at most. What isn't through by then goes with
  the next run (the spool: the newest 100 within 64 KB). After a send that
  never reached Coldread, runs skip the wait (up to 10 minutes): each run's
  startup connection checks whether the network is back, getting 50 ms more
  at exit, and the first that connects ends it.
- `cr.Agent()` (nil when no agent runs it), `cr.Headers("")` (an `http.Header`
  for the CLI's own API calls: use it if the CLI calls an API you also track,
  or each run shows again as an "Unknown" API request), `InferParent: true`, and the one-time notice
  (`NoNotice: true` hides it): as for `@coldread/cli`.
- Add the same Telemetry section to the README.

### Verify

1. Run real commands with `COLDREAD_VERIFY=1`, not under `go test`:
   `COLDREAD_VERIFY=1 go run . deploy preview` (the CLI's main package), then
   the bare `COLDREAD_VERIFY=1 go run .` (the root command; skip it if it
   starts something long-running; if it only prints help, `nothing sent` is
   right). Each sends its event for real, waits, and
   prints `[coldread] verify: accepted` on stderr. That's the signal. Anything else
   says why, as for `@coldread/cli`.
2. The Coldread dashboard also shows it: https://coldread.apappas.dev/app turns live. Verify runs
   are marked and count nowhere.

## MCP server: @coldread/cli/mcp

First inspect the deployed entry point, runtime, transport and actual tool registration.
This sender supports Node/Bun, not Supabase Edge/Deno or Workers. Do not install
it there or substitute HTTP tracking for tool tracking. For Supabase Edge/Deno
with official legacy TypeScript MCP SDK 1.32.2 registerTool, use
`npm:@coldread/track@0.4.0/mcp`. Modern SDK 2.x, mcp-lite and hosted Workers
are unvalidated; report the runtime/SDK gap rather than migrating hosting.

```ts
import { trackToolRegistrations } from "npm:@coldread/track@0.4.0/mcp";
const tracked = trackToolRegistrations(server, {
  key: Deno.env.get("COLDREAD_MCP_PUBLIC_KEY"), // this site's existing cr_pub_ key
  tool: { name: "your-server", version: "1.0.0" },
  disabled: Boolean(Deno.env.get("DO_NOT_TRACK") || Deno.env.get("COLDREAD_DISABLED")),
  waitUntil: work => EdgeRuntime.waitUntil(work),
});
// Register tools through tracked.registerTool BEFORE connecting server.
// Preserve existing authentication and streaming transport; never stack wrappers.
```

Handler execution is captured; pre-handler auth, unknown-tool and schema rejection
are not. No arguments, results, raw errors or credentials are collected. Diagnostics
contain event ID, tool name, time, outcome and HTTP delivery status only. HTTP
acknowledgement is not proof of durable persistence. Use the lifecycle hook; without
it the send is awaited with a bounded timeout. Telemetry fails open with no retries.

HTTP middleware records requests, including discovery and authentication. It does
not prove a tool ran. Verify the actual source and tool name in stored activity
for the correct site, date and normal/test scope. A package, configured key,
HTTP acknowledgement, crawler hit or successful chat answer is not that receipt.
Missing client identity is different from missing calls: use all recorded MCP
calls to check delivery. Keep client declarations reported, never verified;
missing metadata stays unknown. Never invent ChatGPT from the deployment owner.


The official TypeScript SDK, `@modelcontextprotocol/sdk` 1.20+. `McpServer` or
the low-level `Server`, before or after tools are registered. Python: see
"Python" above. Go: see "MCP server (Go)" below. Other SDKs (Rust):
unsupported. See Not supported above.

```sh
npm i @coldread/cli
```

```ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { withColdread } from "@coldread/cli/mcp";

const server = withColdread(new McpServer({ name: "acme", version: "1.2.0" }), { key: "cr_pub_...", tool: "acme-mcp", version: "1.2.0", optOut: "ACME_NO_TELEMETRY" });
```

- Not the Next.js `withColdread`: same name, different package.
- Remote (HTTP) server: add `env: false`. stdio: leave it out.
- Each `tools/call` is one event: tool name, failed or not, duration, the client
  that called. Never results. Nothing is written to stdout. A failed call also
  sends its arguments and the tool's error text, cleaned; `errorDetail: false`
  turns that off. Leave it on: secrets, emails, paths,
  ids and IPs are already removed (a path keeps its file name). Turn it off
  only when arguments are someone's own words (a prompt, a message).
- Each event is on disk as the call ends: a client that stops the server hard
  (SIGINT, SIGTERM, then SIGKILL) loses nothing; what a kill leaves there goes
  about a second after the next start, call or no call. No signal handler is
  added.
- Sessions: the agent's own, Codex's from its call metadata, else the
  transport's. The stateless protocol (2026-07-28) carries none: those calls
  have no session.
- Opt out: `DO_NOT_TRACK`, `COLDREAD_DISABLED`, your `optOut`.
- The server's own tests: a test client that starts it (stdio) hands it an
  environment of its own, where no test runner shows. Put `ACME_NO_TELEMETRY=1`
  (your opt-out) or `COLDREAD_DISABLED=1` in the env you give the server there.
- Add a Telemetry section to the README (a server's own words: tools, not
  commands):

```md
## Telemetry

Acme's MCP server sends anonymous usage data to its developers through
Coldread: which tool was called, whether it succeeded, how long it took,
which AI client called it (and its version), and your OS and architecture.
A failed call also sends its arguments and error message, with secrets,
emails, file paths and IP addresses removed. Never the results, your
hostname or username. Your IP address is not stored.

To opt out, set `ACME_NO_TELEMETRY=1` or `DO_NOT_TRACK=1`; to send no
arguments or error messages, `COLDREAD_ERROR_DETAIL=off`.
```

### Verify

1. Start the server with `COLDREAD_VERIFY=1` in its environment and call any
   tool once from a client (Claude Code, Cursor, Codex, MCP Inspector, or a
   short script with the SDK's `Client`). Each call is sent at once and the
   server's stderr (never stdout) prints `[coldread] verify: accepted`.
   That's the signal. Anything else says why, as for the CLI.
2. The Coldread dashboard also shows it: https://coldread.apappas.dev/app turns live. Verify calls
   are marked and count nowhere; real calls show in Visits as
   `mcp · <tool>`. A client that isn't a known agent (your test script) is
   listed as unknown, not as an agent.

## MCP server (Go): coldread.apappas.dev/go

The official SDK, `github.com/modelcontextprotocol/go-sdk` 1.0+, or mcp-go,
`github.com/mark3labs/mcp-go` 0.43.1+. Go 1.23+. Before or after tools are
added.

### Official SDK

```sh
go get coldread.apappas.dev/go/crmcp
```

```go
import (
	"context"
	"errors"
	"log"

	"coldread.apappas.dev/go"
	"coldread.apappas.dev/go/crmcp"
	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func main() {
	cr := coldread.NewMCP(coldread.MCPOptions{Key: "cr_pub_...", Tool: "acme-mcp", Version: "1.2.0", OptOut: "ACME_NO_TELEMETRY"})
	defer cr.Close()

	server := mcp.NewServer(&mcp.Implementation{Name: "acme", Version: "1.2.0"}, nil)
	server.AddReceivingMiddleware(crmcp.Middleware(cr))
	// your tools, then:
	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil && !errors.Is(err, context.Canceled) {
		cr.Close() // log.Fatal skips the deferred Close
		log.Fatal(err)
	}
}
```

### mcp-go

```sh
go get coldread.apappas.dev/go/crmcpgo
```

```go
import (
	"context"
	"errors"
	"log"

	"coldread.apappas.dev/go"
	"coldread.apappas.dev/go/crmcpgo"
	"github.com/mark3labs/mcp-go/server"
)

func main() {
	cr := coldread.NewMCP(coldread.MCPOptions{Key: "cr_pub_...", Tool: "acme-mcp", Version: "1.2.0", OptOut: "ACME_NO_TELEMETRY"})
	defer cr.Close()

	s := server.NewMCPServer("acme", "1.2.0",
		server.WithToolHandlerMiddleware(crmcpgo.Middleware(cr)),
		server.WithHooks(crmcpgo.Hooks(cr))) // calls to tools that don't exist
	// your tools, then:
	if err := server.ServeStdio(s); err != nil && !errors.Is(err, context.Canceled) {
		cr.Close() // log.Fatal skips the deferred Close
		log.Fatal(err)
	}
}
```

Already on mcp-go 0.43.1 or later: keep the version you have (`go get` can
pull a newer one: pin it back with `go get github.com/mark3labs/mcp-go@<yours>`
if it moved). Older: `go get github.com/mark3labs/mcp-go@v0.43.1`, the
smallest step. Then `go mod tidy`.

- `Key`: the site's `cr_pub_` key, as a literal. `Tool`: the server's name.
  `Version`, `OptOut`: as for the CLI. `DO_NOT_TRACK=1` and
  `COLDREAD_DISABLED=1` always work.
- Remote (HTTP) server: add `Remote: true`. stdio: leave it out.
- One `coldread.NewMCP` per process; a server per session shares it.
- Each event is on disk as the call ends; what a kill leaves there goes about
  a second after the next start, call or no call. No signal is caught. Your
  own tests: as for `@coldread/cli/mcp`. `cr.Close()` sends what's left when the server
  stops, waiting 2 s at most: keep the `defer`, and call it before any
  `log.Fatal` or `os.Exit` (they skip it). A client closing the server is
  `context.Canceled`, not an error.
- Each `tools/call` is one event: tool name, failed or not, duration, the client
  that called. Never results. Nothing is written to stdout. A failed call also
  sends its arguments and the tool's error text, cleaned; `NoErrorDetail: true`
  turns that off. Leave it on: secrets, emails, paths, ids and IPs are already
  removed (a path keeps its file name). Turn it off only when arguments are
  someone's own words (a prompt, a message). With mcp-go, `crmcpgo.Hooks` records calls to tools that don't exist (mcp-go
  refuses them before the middleware); your own hooks: `crmcpgo.AddHooks`.
- Add the MCP server's Telemetry section (as for `@coldread/cli/mcp`) to the README.

### Verify

1. Start the server with `COLDREAD_VERIFY=1` in its environment (`go run .`,
   not under `go test`) and call any tool once from a client. Each call is sent
   at once and the server's stderr (never stdout) prints
   `[coldread] verify: accepted`. That's the signal. Anything else says why:
   `rejected (...)`, `not sending (...)`.
2. The Coldread dashboard also shows it: https://coldread.apappas.dev/app turns live. Verify calls
   are marked and count nowhere; real calls show in Visits as
   `mcp · <tool>`. A client that isn't a known agent (your test script) is
   listed as unknown, not as an agent.

## Ask Coldread

Read scope: CLI reads accept `--traffic normal|test`; MCP read tools accept
`traffic: "normal"` or `traffic: "test"`. Normal is the default. Every successful
response includes `traffic`, including empty results; text CLI output prints it.
This is independent of `COLDREAD_TEST`, which marks the CLI/server's own usage
telemetry and does not select which traffic it reads. Test responses link to the
test dashboard. Existing HTTP clients can continue using `?test=1`; the API also
accepts `?traffic=test` (conflicting scope parameters are rejected).

```sh
npx -y @coldread/cli events --traffic test --surface mcp --json
```


Read what the dashboard shows, then fix the code or write the brief. Key:
`COLDREAD_KEY` (environment or `.env`), else the one `init` saved in
`~/.config/coldread/keys.json`. Another site's key:
`echo <cr_sec_...> | npx -y @coldread/cli key set`.

```sh
npx -y @coldread/cli summary
npx -y @coldread/cli failed --surface cli
npx -y @coldread/cli usage --surface mcp
npx -y @coldread/cli agents --verdict impostor
npx -y @coldread/cli query --measure requests --by agent --event cli
npx -y @coldread/cli events --agent claude-code --limit 50
```

Each: `--json` (the API's answer), `--range` 24h, 7d, 30d, 90d or 1y (default
7d), `--site <name>` when several are saved.

### MCP

Local (stdio). Tools: `summary`, `failed`, `usage`, `agents`, `query`, `events`.

Claude Code:

```sh
claude mcp add --transport stdio coldread -- npx -y @coldread/cli mcp
```

Codex:

```sh
codex mcp add coldread -- npx -y @coldread/cli mcp
```

Cursor (`.cursor/mcp.json`), or a project's `.mcp.json` for Claude Code:

```json
{
  "mcpServers": {
    "coldread": {
      "command": "npx",
      "args": [
        "-y",
        "@coldread/cli",
        "mcp"
      ]
    }
  }
}
```

### Prompts

The MCP server's prompts:

- `fix-top-failure`: Find what fails most on a surface and fix it in this repo.
- `weekly-brief`: Last week as a one-pager: what grew, what broke, what to fix.
- `agent-check`: After a release: did agents succeed more?

## API

For a coding agent or CI: read the site, get its fixes, see its agent
traffic, check the install. The site's secret key (`cr_sec_...`, the same
`COLDREAD_KEY`) in a header, never in a URL. A key sees only its own site.
`cr_pub_` keys are refused. JSON, never cached.

- `POST /api/v1/reads`, optional body `{"url": "https://..."}`, a page on the
  site (default: its homepage). 202 `{ slug, status_url, report_url }`. Each
  read counts against the account's reads for the month; none left: 402.
- `GET /api/v1/reads/<slug>?wait=25`: waits up to 25 seconds for it to finish.
  `status`: `queued`, `running`, `failed` (`error`), or `done` with `score`,
  `grade`, `verdict`, `tasks` and `fixes` (`title`, `why`, `fix`, `prompt`).
- `GET /api/v1/agents?range=7d` (24h, 7d, 30d, 90d, 1y; `verdict`, `surface`):
  agent requests, by company and agent, with verdicts and surfaces.
- `GET /api/v1/summary`, `/failed`, `/usage`, `/query`, `/events`: what
  the dashboard shows (`range`, `surface` and the like as parameters).
- `GET /api/v1/verify`: `receiving` (anything in the last day), the latest
  arrival per surface, `last_hour`.

```sh
curl -X POST https://coldread.apappas.dev/api/v1/reads -H "Authorization: Bearer $COLDREAD_KEY"
curl "https://coldread.apappas.dev/api/v1/reads/<slug>?wait=25" -H "Authorization: Bearer $COLDREAD_KEY"
curl "https://coldread.apappas.dev/api/v1/agents?range=7d" -H "Authorization: Bearer $COLDREAD_KEY"
curl https://coldread.apappas.dev/api/v1/verify -H "Authorization: Bearer $COLDREAD_KEY"
```

### Fix loop

1. `POST /api/v1/reads`. Keep `slug`.
2. `GET /api/v1/reads/<slug>?wait=25` until `status` is `done` or `failed`.
3. Apply each fix's `prompt` to the code, in order.
4. Deploy.
5. Read again: back to 1. Each read counts.
6. Stop when `fixes` is empty or the same fixes come back.
