---
name: kestrel-analytics
description: >-
  Install privacy-friendly traffic analytics and a live visitor badge on a
  website. Pairs to the user's Kestrel account, finds or creates the site,
  installs the snippet, and verifies that a real pageview arrived. Use when
  asked to add analytics, a hit counter, or a visitor badge to a site or
  landing page.
---

# Adding Kestrel analytics to a site

Kestrel counts visitors without cookies, without storing addresses, and without
anything for an ad blocker to remove. Installing it is two tags: a script that
counts, and an image that shows the count.

This document is the whole procedure. Follow it in order. **Do not stop after
Step 3** — Step 4 is what tells you whether any of it worked.

Everything below is at `https://kestrel.host`.

---

## Working with the person

Three of the steps here are theirs rather than yours: approving the pairing,
saying which domain is the real one, and saying where the badge goes and what it
looks like. Ask, and wait for the answer. The middle one is already settled if
you were given a site name — the domain came with it, so do not ask again.
Whoever sent you here was probably handed a line ending *"ask me along the way
where the badge should sit and how it should look"* — so those questions are
expected of you, not an interruption.

They may never have heard of Kestrel, and unless they sent you here from a page
of their own you are about to ask them to make an account. One sentence covers
it: it counts visitors without cookies and without storing addresses, the badge
is an image they can put anywhere including a README, and they can take your key
back whenever they like from their own page.

Say what you are doing as you go, and say plainly when it works. Nothing below
needs their password, and nothing below needs anything from them but one click
and two small decisions.

---

## Before you start

**Look for a key you already have.** In order:

1. The `KESTREL_API_KEY` environment variable.
2. `~/.kestrel/key` — a file with the key on one line.

If you find one, skip to Step 2. Pairing again when a key already exists leaves
the user with a list of keys they have to sort out by hand.

**Ask the user for their domain** if you do not already know it — the real
domain the site is served from in production, not `localhost` and not a preview
URL. You need it in Step 2, and getting it wrong is the single commonest way
this ends up silently counting nothing.

**Unless you were given a site name.** A hyphenated name like
`bell-violet-sunlit-iron-valley` means the site already exists and its domain is
already set — Step 2 reads it off the site rather than asking you for it. Do not
ask, and do not substitute a domain of your own.

---

## Step 1 — Pair with the user's account

Only if you have no key.

```bash
curl -sX POST https://kestrel.host/api/pair \
  -H 'Content-Type: application/json' \
  -d '{"label": "<name of the tool you are, e.g. Claude Code>"}'
```

```json
{
  "ok": true,
  "code": "WREN-4207",
  "secret": "…",
  "verify_url": "https://kestrel.host/pair",
  "expires_in": 600,
  "interval": 3
}
```

**Show the user the `code` and the `verify_url`, and stop to let them act.** Say
something like:

> Open https://kestrel.host/pair and enter **WREN-4207** to connect your Kestrel account.
> I'll wait.

If they have never used Kestrel, that page offers to make them an account and
brings them back to the code with it already filled in — so waiting is still the
right thing to do. If they already have one and are signed in — which they are
if they handed you this from a page of their own — approving is a single click
and should take seconds. Either way: do not try to make the account for them,
and never ask them for their password. The key arrives on its own.

Then poll, every `interval` seconds, until it answers:

```bash
curl -sX POST https://kestrel.host/api/pair/poll \
  -H 'Content-Type: application/json' \
  -d '{"secret": "<the secret from above>"}'
```

- `{"status": "pending"}` — they have not approved yet. Wait `interval` seconds
  and ask again. Give up after `expires_in` seconds and tell the user the code
  expired.
- `{"status": "ok", "key": "ksk_…"}` — done.

If they come back and tell you the box is not taking new accounts, that is real
and not something to retry. Stop polling, tell them what they told you, and
leave it there rather than spending the whole `expires_in` window on it.

**Save the key immediately.** It is shown exactly once and cannot be recovered.
Write it to `~/.kestrel/key` with mode 600, or tell the user to export it as
`KESTREL_API_KEY`.

**Never write the key into the project.** Not into a `.env` that is committed,
not into source, not into a config file, not into a README. It is a credential
for the user's whole Kestrel account, not a per-project setting.

---

## Step 2 — Get the site

**If you were given a site name, it already exists. Read it; do not create it.**

```bash
curl -s https://kestrel.host/api/sites/<slug> -H "Authorization: Bearer $KESTREL_API_KEY"
```

That returns everything the POST below does — the same `snippet` and `badge`
strings Step 3 installs — plus `domains`, the hosts this site counts, and
`problem`, which Step 4 is about. Take the domain from `domains` rather than
from anywhere else, and go to Step 3.

Getting this wrong is expensive and quiet. Somebody who signed up in a browser
already has a site with their own domain on it; the domain you would have posted
is the one you worked out from the project, and for a repo that knows itself only
as `myapp.vercel.app` those are not the same string. A different domain makes a
**second site**. They end up with two, the new one permanently empty, and nothing
on either page explains why.

**Otherwise — no site name, and you have their domain — make one:**

```bash
curl -sX POST https://kestrel.host/api/sites \
  -H "Authorization: Bearer $KESTREL_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"domain": "example.com"}'
```

```json
{
  "ok": true,
  "created": true,
  "slug": "bell-violet-sunlit-iron-valley",
  "site_url":  "https://kestrel.host/s/bell-violet-sunlit-iron-valley",
  "badge_url": "https://kestrel.host/b/bell-violet-sunlit-iron-valley",
  "json_url":  "https://kestrel.host/b/bell-violet-sunlit-iron-valley?format=json",
  "snippet": "<script defer src=\"https://kestrel.host/assets/k.js\" data-site=\"bell-violet-sunlit-iron-valley\"></script>",
  "badge": "<a href=\"…\">\n  <img src=\"…\" alt=\"Traffic for this site\" height=\"34\" loading=\"lazy\">\n</a>"
}
```

**This is safe to call twice.** If the account already has a site for that
domain you get that site back with `"created": false`, and nothing is made. Do
not try to avoid calling it — call it, and read `created` if you want to know.

**One site per domain, not per page.** A site counts a whole website. If the
user has three pages on one domain, that is one site and the snippet goes on all
three.

---

## Step 3 — Install the two tags

Use the `snippet` and `badge` strings exactly as they came back.

**`snippet`** — the counting script. Put it on every page you want counted.
Anywhere before `</body>`; inside `<head>` is fine and is the usual place. It is
`defer`red, sets no cookie, stores nothing, and honours Global Privacy Control.

**`badge`** — the visitor badge. **Ask where it goes before you place it**, and
ask how it should look. A footer, an about page, a sidebar, a README — and four
styles in seven colours, which "Styling the badge" below covers. Suggest
something if they have no preference; a footer is the usual answer. But ask
first: this is the part of it they will see every day, and it is going on their
page, not yours.

If they do want it styled differently, set that **before** you paste — the style
decides the `height`, and the `height` lives in the markup. In that order you
paste it once.

**Keep the `height` attribute** on the `<img>`. It is what stops the badge
shifting the rest of the page while it loads.

If the site is a framework rather than plain HTML, put the script wherever that
framework puts third-party tags — a root layout, `app.html`, `_document`, a
`<svelte:head>`. Do not invent a component wrapper for it; it is a plain script
tag and works as one.

The badge does not need the script. They are independent: the script counts, the
badge draws. If both are on the same page the script also keeps the badge's
number live while somebody is reading, with nothing extra to add.

---

## Step 4 — Verify. Do not skip this.

Installing the tag is not the job. Confirming a real pageview arrived is.

```bash
curl -s https://kestrel.host/api/sites/<slug> -H "Authorization: Bearer $KESTREL_API_KEY"
```

Read the **`problem`** field. It is `null` when everything is working, and
otherwise names exactly what is wrong:

```json
{
  "problem": {
    "code": "foreign_host",
    "says": "Hits are arriving from widgetco-git-main.vercel.app, which this site does not count…",
    "fix": "…"
  }
}
```

**The procedure:**

1. Deploy the change, or ask the user to. Nothing arrives from a file on disk.
2. Ask the user to open a page of the site in a browser.
3. Call the endpoint above.
4. If `problem` is `null` and `figures.windows.today.views` is at least 1 — you
   are done. Tell the user, and give them their `site_url`.
5. If `problem` is not `null` — do what its `fix` says, then go back to 3.

**These figures are cached for 60 seconds.** This matters more than it
sounds like it should: if you install the tag, load a page and check straight
away, you will read a zero that was counted *before* the pageview and conclude
the install failed when it did not. `figures.at` is when they were last counted.

So: wait 60 seconds after the pageview before believing a zero, and try at
least twice before treating `nothing_yet` as real. Do not start undoing your own
work on the first empty answer — that is the commonest way this goes wrong, and
the thing you would be "fixing" is a cache.

`https://kestrel.host/b/<slug>?format=json` returns the same figures with no key needed, if
you want a check that does not authenticate. It is cached the same way.

### What can be wrong

| `problem.code` | What happened | What to do |
|---|---|---|
| `foreign_host` | The tag is firing, but from a domain this site does not count — so every hit is dropped. **Usually a preview deploy** (`*.vercel.app`, a netlify preview, a staging domain), or the tag went on a subdomain that was never declared. | Add the host with `POST /api/sites/{slug}/domains` — it takes a list, so live and preview can both be declared. Or install on the real domain and ignore it. |
| `nothing_yet` | Nothing has arrived at all. The tag is not on the page, not deployed, or the page has not been loaded since. | Check the snippet is really in the served HTML — `curl` the live page and grep for `k.js`. Then load the page. |
| `no_domain` | The site has no domain set, so it would count anything. | `POST /api/sites/{slug}/domains`. |
| `null` | Working. | Nothing. |

### You can verify on localhost

`localhost`, `127.0.0.1` and `::1` are always counted, whatever domains the site
declares. That is deliberate, and it is useful to you: **you can run the site
locally, open it in a browser, and complete Step 4 before anything is
deployed.** A local hit is a real hit and proves the tag is wired up correctly.

Tell the user that local visits count, so they are not surprised by a handful of
views they made themselves.

### Two things that look like faults and are not

- **The badge shows a dash.** That is a site with no hits yet, not an error.
- **Your own `curl` of the page does not show up.** Only the script counts, and
  `curl` does not run scripts. Use a browser.

---

## Styling the badge

They were told you would ask how it should look, so offer rather than wait to be
asked. Describe the choice in a line or two rather than pasting a table at them:

```bash
curl -s https://kestrel.host/api/badge/shape          # every option, and what each is worth
```

Four styles, seven colours or any hex, light or dark, and a choice of which
figure it shows. The default is deliberate and sits well on most sites, so
"leave it as it is" is a real answer and not a decision they failed to make.

To change it:

```bash
curl -sX POST https://kestrel.host/api/sites/<slug>/badge \
  -H "Authorization: Bearer $KESTREL_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"style": "pill", "color": "violet", "metric": "visitors"}'
```

Anything you leave out keeps what was saved. The badge address carries no
options, so this changes every copy already pasted anywhere — **except the
`height`**, which lives in the markup. If the response's `height` differs from
what is in the page, paste the returned `badge` again.

---

## When it is working

Step 4 has passed. Say so — they have been waiting on it — and tell them four
things:

- **Their `site_url`.** A real page they can open now, share, or link from a
  README. It needs no sign-in and it is theirs.
- **Where the badge went**, so they can move it if it is not where they had
  pictured it.
- **That their own visits count.** `localhost` is counted on purpose, so the
  first few views on that page are theirs and nothing has gone wrong.
- **Where your key lives and how to take it back** — their own page, which
  `GET /api/me` hands back as `account_url`. Say it plainly rather than leaving
  it to be found. Being able to undo this is most of the reason they said yes.

Then stop. There is nothing else to set up: no goals, no funnels, no events to
define, no second script. It counts from here.

---

## Rules

1. **Never commit the key.** Environment or `~/.kestrel/key`, mode 600. Nowhere
   else, ever.
2. **Never put the key in a URL.** Header only. It is refused anywhere else.
3. **Do not pair if a key already exists.** Check first.
4. **One site per domain.** Given a site name, `GET` it. Otherwise `POST
   /api/sites` and read `created`. Never post a domain you inferred when you
   were handed a site.
5. **Do not report success until Step 4 passes.** "I added the tag" is not the
   same claim as "analytics is working", and only one of them is checkable. When
   it does pass, say so plainly — that is the sentence they are waiting for.
6. **Ask about the badge; do not decide for them.** Where it sits and how it
   looks are theirs, and they were told you would ask.
7. **Tell the user their `site_url`.** It is a public page they can share, and
   it is where they change things by hand.

## Limits

- 10 sites per account added in a browser, and 10 more
  added through this API. They are counted separately.
- 3 domains per site. Subdomains of each are included automatically.
- 10 keys per account. The user can revoke any of them from their own
  page at `https://kestrel.host/a/<their-account>`.
- Site creation is limited per key per hour. If you get `429`, you are looping —
  stop and look at what you are doing.

## Errors

Every failure is a non-2xx status and a body of this shape:

```json
{"ok": false, "error": "bad_domain", "reason": "A sentence you can show the user."}
```

Branch on `error`. Show `reason`. The ones worth handling:

| Status | `error` | Meaning |
|---|---|---|
| 400 | `bad_domain` | Not a hostname. Send just `example.com`. |
| 401 | `no_key` / `bad_key` | Missing, revoked, or not a key. Pair again. |
| 404 | `not_found` | No such site on this account. |
| 409 | `no_room` | The agent site pool is full. The user must free one or add it in a browser. |
| 410 | `pairing_gone` | The code expired or was already used. Start Step 1 again. |
| 429 | `too_many` | Slow down. |
| 503 | `unavailable` | The box has a problem. Retrying later is reasonable. |

## The full endpoint list

`GET https://kestrel.host/api` returns it, and it is the authority if this document and the
API ever disagree.
