01 / 12
Start here
Luku is website analytics that a coding agent installs, proves, and reads. There is no dashboard. You ask your agent a question in chat; it calls Luku and answers in prose.
The whole loop is:
create → install the tag → verify → define events → read → mark_release → compare
You do not run that loop. Your agent does.
The one-line version
Say this to Cursor, Claude Code, Windsurf, or any agent that can fetch a URL:
use luku.io
It reads /llms.txt, creates a site, adds one script tag, loads the
page in a browser it drives itself, and confirms real events arrived. Nothing on
this page is a prerequisite — it is here for when you want to know what your
agent just did, or when you want to do a piece of it by hand.
Doing it by hand
npx @lukuio/cli init --name "<site name>"
That returns a site id (st_…), a workspace token (lk_…), a server write key
(wk_…), the HTML snippet, and a claim link. Same thing over HTTP, with no
install:
curl -X POST https://luku.io/api/v1/quickstart \
-H 'content-type: application/json' \
-d '{"name":"<site name>"}'
Use your project's real name. There is no URL to pass — the domains your site serves from are learned from live traffic. No signup, no email prompt. The workspace is anonymous until you claim it, and you only ever need to claim it for billing and credential recovery.
Three credentials, three places
| Credential | Prefix | Lives in | Grants |
|---|---|---|---|
| Site id | st_ |
your committed HTML, publicly visible | write-only ingest |
| Write key | wk_ |
your server environment | server-side ingest |
| Workspace token | lk_ |
your agent's MCP config | full read + write |
lk_ and wk_ must never appear in browser code. The site id is public by
design — it is in the script tag on every page.
What you get
Traffic (views, visitors, referrers, countries, devices, paths) is collected automatically. Attention — how far down each page people actually get, and how long they linger per section — and friction — dead clicks, rage clicks, JS errors, each with a CSS selector your agent can grep for — are collected automatically too.
Meaning is not automatic. Business outcomes like signup_completed are
declared and instrumented, because a div_clicked pile is not an
answer to "are people buying".
02 / 12
Installing the tag
One script tag, before </head>, on every page:
<script defer src="https://luku.io/m.js" data-site="st_abc123"></script>
That covers static HTML, Next, Astro, Nuxt, Remix, SvelteKit, Vite, WordPress,
Webflow, Shopify, Squarespace, Framer, and Ghost. There is no build step and no
package to install. Single-page routing is handled for you — the beacon patches
history.pushState / replaceState and listens for popstate and hashchange,
so client-side navigations become page views without configuration.
The beacon is under 4KB gzipped, enforced by a CI size check.
Per-framework recipes
Ask your agent for get_install and it returns the exact code and the exact file
path, so it does not have to guess:
| Platform | Target file | Form |
|---|---|---|
| Plain HTML | before </head> |
script tag |
| Next App Router | app/layout.tsx |
next/script, strategy="afterInteractive" |
| Next Pages | pages/_document.tsx |
script tag inside <Head> |
| Astro | src/layouts/Layout.astro |
script tag (implicitly is:inline) |
| Nuxt | nuxt.config.ts |
app.head.script[] |
| SvelteKit | src/app.html |
script tag |
| Remix | app/root.tsx |
script tag in <head> |
| Vite / CRA | index.html |
script tag |
| WordPress | functions.php or a header-scripts plugin |
echo on wp_head |
| Webflow | Site settings → Custom Code → Head | script tag |
| Shopify | layout/theme.liquid |
script tag before </head> |
| Squarespace · Framer · Ghost | settings pane | script tag |
Each recipe also says whether the tag is server-rendered. Next App Router injects
it client-side, so check_install (which only reads server HTML) will not see it
there — verify is the proof in that case.
Optional attributes
| Attribute | Effect |
|---|---|
data-api |
send to your own first-party proxy origin instead of Luku |
data-release |
tag every event from this build with a release id |
data-exclude |
comma-separated path patterns to skip |
data-no-attention |
turn off per-section attention collection |
data-no-friction |
turn off dead click, rage click, and JS error capture |
Typed helper
If you want types, or you want to fire events before the beacon has loaded:
npm i @lukuio/js
import { track, release, flush } from '@lukuio/js';
track('signup_completed', { plan: 'pro' }, { value: 199 });
It queues calls made before the script loads, so nothing is lost on a fast
interaction. The package ships a <LukuScript /> component for React and Next.
Server-side events
Some events must not depend on a browser. Purchases are the obvious one: an ad
blocker or a closed tab should never lose a completed sale. Post them from your
server with the wk_ write key:
curl -X POST https://a.luku.io/e/s \
-H 'authorization: Bearer wk_…' \
-H 'content-type: application/json' \
-d '{
"site": "st_abc123",
"events": [{
"name": "purchase_completed",
"value": 199,
"props": { "plan": "pro", "currency": "EUR" },
"path": "/checkout/success"
}]
}'
Returns 202 { "accepted": 1 }. Pass an optional visitor_id to stitch the
event onto a browser session, and an optional ts to backdate it. This is the
only correct place to record purchase_completed — Stripe webhooks, server
actions, and background jobs all belong here.
Ad blockers and CSP
Roughly a tenth of traffic blocks third-party analytics domains. If that matters,
proxy Luku through your own origin: map /m.js and /e onto Luku with a rewrite
rule, load the script from /m.js, and drop data-api — the beacon posts to the
origin it was loaded from. The Next recipes from get_install include the
next.config.js rewrite; other hosts need the equivalent two rules.
If you run a Content Security Policy, every get_install recipe includes the
script-src and connect-src origins to allow.
03 / 12
Verifying the install
Every other analytics tool ends installation with "paste this and hope". Luku
ends it with proof. verify is the primitive the rest of the product is built
around, and your agent should call it before it ever tells you analytics is
working.
How it works
verifyopens a verification window — a short-lived session id.- While that window is open, ingest additionally writes every event to an exact, unsampled table that can be read back immediately.
- Your agent loads the returned
verify_urlin a browser it controls. verifyreturns exactly what arrived and exactly what did not.- The window closes on its own, and steady-state collection goes back to a single write with no database contact.
Verification never reads from the sampled analytics store, because sampled, lagged data cannot prove that a specific event fired.
Verify locally first
The fastest proof is against the dev server you already have running. Localhost buffers verification events while being excluded from analytics entirely — it never becomes a view, a visitor, or a learned origin. So verifying locally costs nothing, proves the install, and skips the deploy wait.
After you deploy, check_install does a server-side fetch of the live URL and
looks for the tag in the returned HTML — sub-second, no browser, and it catches
"you forgot to deploy" before anything else is investigated. Then verify against
production.
What it returns
{
"beacon": "receiving",
"verify_url": "https://example.com/?luku_verify=vs_…",
"observed": [{ "name": "page_view", "count": 3, "paths": ["/", "/pricing"] }],
"missing": ["signup_completed"],
"status": "partial",
"stages": [
{ "stage": "tag_deployed", "ok": true },
{ "stage": "beacon_reachable", "ok": true },
{ "stage": "event_observed", "ok": null }
],
"ask_user": null
}
The stages array exists so a failure is never ambiguous. tag_deployed means
the tag is in the served HTML; beacon_reachable means the script serves;
event_observed means the round trip completed. blocking names the first stage
known to be broken, so the agent fixes the actual cause instead of guessing.
ask_user is the only field that should ever interrupt you, and it is non-null
only when every server-checkable stage passes and a real browser load is
genuinely the last thing missing. If it is null, your agent is expected to
finish the job without asking you anything.
Calling verify repeatedly is the intended loop. It reuses the site's active
window rather than minting a new one, so events already observed are not lost
between calls.
04 / 12
Events
Traffic collection is automatic. Meaning is not.
Luku deliberately does not record a div_clicked pile and hope something useful
falls out of it. Business outcomes are declared, then fired, then
verified — which is what makes "is checkout working?" a question with an
answer rather than an archaeology project.
Declare the event
define_event({
site: "st_abc123",
name: "signup_completed",
description: "Account created and email confirmed",
value_unit: "EUR"
})
Registering the definition is what lets Luku tell the difference between an event
that is quiet because nobody converted and an event that is quiet because the
code that fires it was deleted in a refactor. events reports each name as
defined, undeclared, or defined_but_silent, and diagnose raises the last
one on its own.
Fire it
After the beacon loads, luku is global:
luku.track('signup_completed', { plan: 'pro' }, { value: 199 });
With the typed package, calls made before the script loads are queued rather than dropped:
import { track } from '@lukuio/js';
track('signup_completed', { plan: 'pro' }, { value: 199 });
Anything involving money should be fired from your server instead, so an ad blocker or a closed tab cannot lose it.
Verify it
verify({ site: "st_abc123", expect: ["signup_completed"] })
Then actually complete the flow — your agent can drive it — and call verify
again. An event that has never been observed is not instrumentation; it is an
intention.
Naming
Lowercase snake_case, object_verb past tense:
signup_started signup_completed
checkout_started purchase_completed
trial_started subscription_cancelled
Pair every x_started with an x_completed. That pairing is what turns two
counters into a conversion rate, and diagnose flags a started event with no
completion as a finding, because it is almost always a gap in instrumentation
rather than a product with a 0% conversion rate.
05 / 12
Attention and friction
These two are why Luku's answers read differently from a pageview counter. Both are collected automatically, with no markup required.
Attention
Sections are detected in priority order: anything marked [data-luku-section],
then <section> / <main> / <article> / <header> / <footer> landmarks,
then viewport deciles as a fallback. Each is named from its id, else the
nearest heading text, else its position on the page.
Time accrues only while the tab is visible and the window is focused, so a page left open in a background tab does not manufacture engagement. Up to 20 sections per page.
attention("/")
Hero 94% seen 3.2s
Benefits 81% seen 4.1s
Demo 63% seen 11.8s
Pricing 47% seen 14.3s
FAQ 18% seen 5.2s
Read that as a drop-off map. More than half of the people who land never reach pricing; the ones who do sit on it for fourteen seconds. That is a layout problem with a specific location, which is exactly the shape of thing an agent can act on.
To name sections yourself:
<section data-luku-section="pricing"> … </section>
Friction
| Signal | Definition |
|---|---|
dead_click |
a click with no interactive ancestor that produced no DOM change and no navigation within 500ms |
rage_click |
three or more clicks within 800ms inside a 30px radius |
js_error |
window.onerror and unhandled promise rejections, truncated, capped at 5 per pageview |
Every finding carries a CSS selector and the nearest text content, which is the part that matters: it is a string your agent can grep for in the repo.
friction("/pricing")
#pricing .card:nth-child(2) img 38× "Most popular"
.faq-item h3 12× "Can I cancel?"
A non-interactive card that people keep clicking is a button that was never built. Luku's job is to hand over the selector; your agent's job is to open the file.
06 / 12
Reading the data
You read Luku by asking your agent a question. These are the tools behind the answer.
| Tool | Returns |
|---|---|
overview |
views, visitors, top referrers, countries, device split, event totals, period-over-period deltas |
pages |
per-path views, visitors, average attention, scroll depth, entry and exit share |
events |
counts, value sums, trend, and whether each name is declared |
attention |
the per-section table for one path |
friction |
dead clicks, rage clicks, and JS errors, grouped by selector |
diagnose |
deterministic findings across all of the above |
Periods
Every read takes the same period grammar:
today · yesterday · 24h · 7d · 28d · 90d · all · release:rel_…
Windows up to 90 days are served from the live event store. all and anything
longer is served from nightly rollups, so history survives past the raw
retention window. Responses say which source answered.
Sampling, stated plainly
Under heavy load, collection samples and weights counts back up. When that
happens, the response carries a note such as "Counts are estimated from a
1-in-8 sample", and unique-visitor figures are approximate. Agents are
instructed to repeat that note verbatim rather than present an estimate as an
exact figure.
Humans and bots are never mixed
Bot and AI-crawler traffic is scored and kept, not dropped — so a spike stays explainable instead of silently vanishing. Reads exclude it by default.
This produces two different kinds of zero, and they mean opposite things. If
nothing at all has arrived, a read returns status: "no_data" with a next step.
If non-human traffic arrived but human traffic did not, it returns
status: "bots_only" with the breakdown — which means your install works and
only real visitors are missing. Collapsing those two would make an agent report a
broken install off the back of a working one.
Live traffic only
Localhost and private-network traffic are never recorded as views or visitors,
and never used to learn which origins belong to your site. They can prove an
install through verify, and that is all.
So a site that has only ever been opened on localhost:3000 correctly reads as
zero. diagnose reports that case as local_only rather than pretending
something is broken.
07 / 12
Releases and diagnose
Mark every deploy
mark_release({ site: "st_abc123", label: "pricing-page-rewrite", git_sha: "9f2a1c4" })
This is the cheapest habit with the highest payoff, and it is the one thing that needs to happen at deploy time rather than after. Without release markers, "did that change help?" is answered by squinting at a date. With them, it is a diff.
Your agent should call mark_release on every deploy without being asked. If you
would rather not rely on that, put the release id straight on the tag:
<script defer src="https://luku.io/m.js"
data-site="st_abc123" data-release="rel_9f2"></script>
Compare two of them
compare({ site: "st_abc123", before: "release:rel_8a1", after: "release:rel_9f2" })
Returns metric deltas with significance flagged, events that newly appeared, events that went silent, and friction that moved. Either side can be a plain period instead of a release.
Diagnose
diagnose runs a fixed catalogue of rules and returns findings, each with a
severity, the evidence behind it, and one suggested action. There is no model
on Luku's side — the thing calling it is already one. Our job is structured
observations it can reason over.
| Rule | Fires when |
|---|---|
missing_completion |
an x_started event exists with no matching x_completed |
defined_never_fired |
a declared event has still never been seen 24h later |
event_stopped |
an event fired regularly, then stopped — correlated with the nearest release |
value_missing |
a valued event starts arriving with value = 0 |
dead_click_hotspot |
20 or more dead clicks on one selector |
rage_hotspot |
10 or more rage bursts on one selector |
cta_seen_not_clicked |
a section is widely seen but its associated event rarely fires |
attention_cliff |
seen-percentage drops more than 40 points between adjacent sections |
traffic_anomaly |
a path deviates more than 40% from its trailing 7-day mean |
conversion_drop_after_release |
conversion falls more than 15% across a release boundary |
js_error_spike |
error volume rises more than 3× versus baseline |
no_events_defined |
real traffic, but zero declared business events |
bot_share_high |
more than 40% of raw hits are automated |
mobile_regression |
a metric is materially worse on mobile than desktop |
A typical finding:
! dead_click_hotspot critical
#pricing .card:nth-child(2) img
38× · "Most popular"
→ make it interactive, or stop looking clickable
08 / 12
MCP
MCP is the primary surface. Streamable HTTP, one endpoint, bearer auth.
Merge this into ~/.cursor/mcp.json — or the equivalent file for Claude Code,
Windsurf, VS Code, or Zed:
{
"mcpServers": {
"luku": {
"url": "https://luku.io/mcp",
"headers": { "Authorization": "Bearer lk_…" }
}
}
}
The lk_ token is returned once by create_site or /api/v1/quickstart. It
belongs in that config file and nowhere else — never in browser code, never in a
committed file.
The fifteen tools
Setup
| Tool | Purpose |
|---|---|
create_site |
create a site, return the snippet and credentials |
list_sites |
sites with status, last event, and recent view count |
get_install |
exact code and file path for a given framework |
Proving it works
| Tool | Purpose |
|---|---|
check_install |
server-side fetch of a URL, looking for the tag |
verify |
open a window and report exactly what arrived |
define_event |
register what a business event means |
Reading
| Tool | Purpose |
|---|---|
overview |
views, visitors, referrers, devices |
pages |
per-path breakdown |
events |
named event counts, value, trend |
attention |
per-section attention for one path |
friction |
dead clicks, rage clicks, JS errors |
Reasoning
| Tool | Purpose |
|---|---|
mark_release |
mark a deploy |
compare |
diff two releases or periods |
diagnose |
deterministic findings |
restrict_origins |
whitelist: only count events from domains you list, drop the rest (off by default) |
Other surfaces
MCP is the primary surface, never the only one. An agent with just a shell has
the CLI and plain curl against the REST API. An agent that can
only fetch a URL has /llms.txt, which documents the whole loop in
a form it can read directly. And there is a SKILL.md for agents that support
skills, teaching the same loop: create, install, verify, declare events, verify
again, mark every release, diagnose when asked what is wrong.
09 / 12
CLI
For an agent that has a shell but no MCP, and for you when you want to check something without opening a chat.
npx @lukuio/cli init [--name "<site name>"] [--force]
npx @lukuio/cli verify [--expect page_view,signup_completed] [--url URL]
npx @lukuio/cli overview [period]
npx @lukuio/cli diagnose
Everything prints JSON, so it pipes into jq and reads cleanly in an agent's
tool output.
--name defaults to the name in the nearest package.json, then the folder
name. There is no URL flag: the domains a site serves from are learned from live
traffic, not declared up front.
init writes .luku.json with the workspace token and site id for this machine.
That file is gitignored and is a local convenience, not the source of truth — the
site id already lives in your committed HTML as data-site, and the token lives
in your MCP config.
init refuses to run when the project already has a site, because minting a
second one leaves your deployed tag reporting to the first, and the new site then
reads as zero traffic. --force overrides it when replacing a site is actually
what you meant.
The CLI is a thin client over the REST API. It is not a second control plane, and it will not grow one.
10 / 12
REST API
Every MCP tool has a REST equivalent, because an agent with only a shell should
still be fully capable. Base path /api/v1, bearer auth with your lk_ token.
| Method | Path |
|---|---|
POST |
/quickstart — unauthenticated cold start, returns workspace, tokens, site, snippet, MCP config |
POST GET |
/sites |
GET PATCH |
/sites/:id |
GET |
/sites/:id/install?platform= |
POST |
/sites/:id/verify · GET /sites/:id/verify/:vs |
POST |
/sites/:id/check-install |
GET |
/sites/:id/overview · pages · events · attention · friction · diagnose — all take ?period= |
POST GET |
/sites/:id/events/defs |
POST GET |
/sites/:id/releases |
GET |
/sites/:id/compare?before=&after= |
POST |
/workspaces/link · /workspaces/claim |
Errors
{
"error": {
"code": "site_not_found",
"message": "No site with id st_abc123.",
"hint": "Call list_sites, or create_site if this project has none."
}
}
Codes: unauthorized · forbidden · site_not_found · not_found ·
invalid_period · rate_limited · plan_limit · upstream_unavailable.
Every error carries a hint, because the reader is usually an agent deciding
what to do next rather than a human reading a stack trace.
"No data yet" is not an error. It returns 200 with status: "no_data" and
a next step. Agents recover from a successful empty response and tend to abort on
a 4xx.
Limits
| Limit | Free | Pro |
|---|---|---|
| Data points per site per day | 50k | 2M |
| REST requests | 120/min per token | |
| MCP requests | 300/min per token | |
| Server-side ingest | 600/min per write key | |
| Site creation | 20/hour per IP |
A page view with attention and friction is a handful of data points, not one — a site doing 100k page views a month lands comfortably inside the free tier.
11 / 12
Privacy and ownership
No cookies, no local storage, no identity
Visitors are counted with a daily rotating hash. The salt rotates every day and the inputs are never stored, so the same person on the same site is a different number tomorrow and there is nothing to join across sites. No cookie banner is required by this design in most readings of EU law — and nothing here needs a consent dialog you would have to build.
Never collected: input values, form contents, text selections, keystrokes,
localStorage, cookies, full query strings other than UTM parameters, or raw IP
addresses.
No session replay. No cross-device stitching. No ad pixels. No warehouse export.
Nothing to configure
There is no settings page, and that is a design constraint rather than an omission. Origin restriction is opt-in and off by default, because enforcement is the one feature here that can make a live site report zero views — which is the worst failure this product has. Origins are learned additively from production traffic, never pinned automatically, and never learned from localhost.
If you do want an allowlist, restrict_origins sets one, and it will tell you
which already-learned origins just stopped counting.
Ownership
A workspace is created anonymously. No signup, no email prompt, nothing to fill in before data starts arriving. Your agent gets a claim link back and should relay it to you.
Claiming exists for exactly two reasons: billing, and recovering credentials if you lose the token. Claiming always requires a one-time code emailed to the address that started it — the claim link is designed to travel through agent chat logs, so possession of the link alone can never be enough.
Until you claim it, Luku holds no email address for you and cannot contact you.
12 / 12
When something looks wrong
The numbers are zero
Work down the chain in order, because each step rules out everything below it.
- Is the tag deployed?
check_installfetches the live URL and looks for it. Sub-second, no browser. This catches "committed but not deployed", which is the most common cause by a wide margin. - Do events arrive at all?
verifyagainst localhost first. If that goes green, the beacon and your instrumentation both work, and the problem is production-specific. - Has anyone actually visited? Only live production traffic is recorded.
Localhost and private networks prove installs but never become numbers, so a
site nobody has visited yet correctly reads as zero.
diagnosereports that case aslocal_only. - Is it all bots?
status: "bots_only"means events are arriving and being classified as automated. The install works; human traffic is what is missing.
An event never fires
events marks it defined_but_silent and diagnose raises
defined_never_fired once 24 hours have passed. Usually the call sits behind a
branch that never runs — an email confirmation step, a success page that
redirects before the beacon flushes, or a handler that was removed in a refactor.
Anything that involves money should be fired from your server instead, where no ad blocker or closed tab can lose it.
Counts look lower than another tool
Two likely reasons, and they are both real rather than a bug.
Ad blockers remove roughly a tenth of third-party analytics traffic. Proxying
Luku through your own origin recovers most of it — get_install returns the
rewrite rules.
Bots are excluded from reads by default. If another tool counts them, its number
will be higher and less useful. overview returns the automated breakdown
alongside the human figures, so you can see the difference rather than guess at
it.
Numbers are labelled as estimates
Under heavy load, collection samples and weights counts back up. When it does, the response carries a note saying so and unique-visitor figures are approximate. Agents are instructed to repeat that note rather than present an estimate as exact.
Still stuck
diagnose is the single call that runs every rule at once. Give its output to
your agent — the findings carry the evidence and a suggested action, which is
usually enough for it to go and open the right file.