Docs

How Luku works.

You should not need this page. Say use luku.io to your coding agent and it runs the whole loop from /llms.txt. This is here for when you want to know what it did, or do a piece of it yourself.

Tell your coding agent

› use luku.io

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

  1. verify opens a verification window — a short-lived session id.
  2. While that window is open, ingest additionally writes every event to an exact, unsampled table that can be read back immediately.
  3. Your agent loads the returned verify_url in a browser it controls.
  4. verify returns exactly what arrived and exactly what did not.
  5. 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.

  1. Is the tag deployed? check_install fetches 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.
  2. Do events arrive at all? verify against localhost first. If that goes green, the beacon and your instrumentation both work, and the problem is production-specific.
  3. 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. diagnose reports that case as local_only.
  4. 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.

Start with a prompt

Let your agent do the reading.

Open the project you want to measure. Your agent handles the install, verification, and first answer.

Paste into Cursor, Claude Code, or any agent

› use luku.io