Build your own dashboard
Capless has no dashboard you have to learn. Your coding agent builds the one you want, inside your own app, with exactly the numbers you care about. Give it this prompt:
Build me an analytics dashboard from my Capless data. Follow https://analytics.capless.sh/docs/dashboards.md step by step.
It works with Claude Code, Cursor, Codex or any agent that can fetch a URL. The rest of this page is written for that agent.
For agents: what to build
- Ask first, briefly. What should the dashboard answer? Which pages or events matter (signup, checkout, a
data-caplessbutton)? If the user doesn't say, build the default: visitors, sessions and pageviews per day, top pages, where visitors come from, countries, custom events, and one funnel, for the last 30 days with a 7/30/90-day switch. - Put it where the user already works. If the project already has an admin panel or internal tool (an
/adminarea, a back office, Retool or a similar tool), add the analytics there as a new page or section, using its layout, sign-in and chart components. Otherwise build an admin-only page in their app, behind the app's own sign-in (for exampleapp/admin/analytics/page.tsxin Next.js, or a route in their server framework). If there is no app with a server, ask; a script that writes a static HTML report works too. - Make it fast: query a ClickHouse materialization with SQL (milliseconds), cache each query's result for about 60 seconds, and run the queries in parallel, at most 4 at a time (a materialization runs 4 queries at once; more fail with
TOO_MANY_SIMULTANEOUS_QUERIES). - Keep the API key on the server. The page's server code calls Capless; the browser only gets the numbers. Never put
cpl_…in client code or commit it.
Send the user's request as the intent on every call (Capless-Intent header, URL-encoded), for example Capless-Intent: build%20an%20analytics%20dashboard. If something here doesn't work, report it with POST /v1/feedback.
1. Credentials
You need an API key (cpl_…) of a claimed project, in a server-side environment variable such as CAPLESS_API_KEY. The user has one from signup, or creates one in the Capless app (app.capless.sh → API keys). A claim secret (csk_…) cannot create materializations (403 claim_required): ask the user to claim the project first.
H="authorization: Bearer $CAPLESS_API_KEY"
2. A ClickHouse copy of the web events
Reuse an existing one: GET /v1/materializations and look for the name dashboard. Otherwise create it:
curl -s https://api.capless.sh/v1/materializations -H "$H" -d '{"name":"dashboard","type":"clickhouse"}'
export MAT=mat_… # the id from the response
Create one wide events table (send it as {"sql": "…"} to POST /v1/materializations/$MAT/query):
CREATE TABLE IF NOT EXISTS events (
_capless_id String,
ts DateTime64(3),
type LowCardinality(String),
name String,
host LowCardinality(String),
path String,
referrer_host String,
utm_source String,
utm_medium String,
utm_campaign String,
country LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device_type LowCardinality(String),
session_id String,
anonymous_id String,
user_id String,
is_bot UInt8
) ENGINE = MergeTree ORDER BY (type, ts)
Connect $web.events to it (POST /v1/materializations/$MAT/sources with {"stream": "$web.events", "table": "events", "map": "…"}), with this map:
{'ts': entry.timestamp, 'type': entry.type,
'name': has(entry.name) ? entry.name : '',
'host': has(entry.host) ? entry.host : '',
'path': has(entry.path) ? entry.path : '',
'referrer_host': has(entry.referrerHost) ? entry.referrerHost : '',
'utm_source': has(entry.utm) && has(entry.utm.source) ? entry.utm.source : '',
'utm_medium': has(entry.utm) && has(entry.utm.medium) ? entry.utm.medium : '',
'utm_campaign': has(entry.utm) && has(entry.utm.campaign) ? entry.utm.campaign : '',
'country': has(entry.geo) && has(entry.geo.country) ? entry.geo.country : '',
'browser': has(entry.device) && has(entry.device.browser) ? entry.device.browser : '',
'os': has(entry.device) && has(entry.device.os) ? entry.device.os : '',
'device_type': has(entry.device) && has(entry.device.deviceType) ? entry.device.deviceType : '',
'session_id': has(entry.sessionId) ? entry.sessionId : '',
'anonymous_id': has(entry.anonymousId) ? entry.anonymousId : '',
'user_id': has(entry.userId) && entry.userId != null ? entry.userId : '',
'is_bot': has(entry.isBot) && entry.isBot ? 1 : 0}
The source copies all past events, then follows the stream: new events reach the table within about a minute. GET /v1/materializations/$MAT/status says current once it has caught up. Over MCP, the same steps are create_materialization, run_sql and connect_stream. Need more fields (scroll depth, Web Vitals, custom event properties, click targets)? Capless owns the managed table's columns, so create a second table and connect $web.events to it again with a wider map (and a filter, such as entry.type == 'pageleave'); see the collected fields.
3. Queries
Run each with POST /v1/materializations/$MAT/query and {"sql": "…", "parameters": {"days": 30}}. Results are {columns, rows}. Always pass values as {name:Type} parameters, never by string concatenation. Bots are counted in Capless on purpose; dashboards usually hide them (NOT is_bot). To leave out the user's own testing, ask for their hosts and add AND host NOT IN ('localhost', '127.0.0.1').
Visitors, sessions and pageviews per day:
SELECT toDate(ts) AS day, uniq(anonymous_id) AS visitors, uniq(session_id) AS sessions, countIf(type = 'pageview') AS pageviews
FROM events
WHERE ts >= now() - INTERVAL {days:UInt32} DAY AND NOT is_bot
GROUP BY day ORDER BY day
Top pages:
SELECT path, count() AS views, uniq(anonymous_id) AS visitors
FROM events
WHERE type = 'pageview' AND ts >= now() - INTERVAL {days:UInt32} DAY AND NOT is_bot
GROUP BY path ORDER BY views DESC LIMIT 20
Where sessions come from (the first page of each session):
SELECT source, count() AS sessions FROM (
SELECT session_id,
argMin(if(utm_source != '', utm_source, if(referrer_host = '' OR referrer_host = host, '(direct)', referrer_host)), ts) AS source
FROM events
WHERE type = 'pageview' AND ts >= now() - INTERVAL {days:UInt32} DAY AND NOT is_bot
GROUP BY session_id
) GROUP BY source ORDER BY sessions DESC LIMIT 20
Countries:
SELECT if(country = '', '(unknown)', country) AS country, uniq(anonymous_id) AS visitors
FROM events
WHERE ts >= now() - INTERVAL {days:UInt32} DAY AND NOT is_bot
GROUP BY country ORDER BY visitors DESC LIMIT 20
Custom events (capless("track", …)):
SELECT name, count() AS times, uniq(anonymous_id) AS people
FROM events
WHERE type = 'custom' AND ts >= now() - INTERVAL {days:UInt32} DAY AND NOT is_bot
GROUP BY name ORDER BY times DESC LIMIT 20
A funnel (visitors reaching each step within a day, in order; choose the steps with the user):
SELECT level, count() AS visitors FROM (
SELECT anonymous_id,
windowFunnel(86400)(toDateTime(ts), type = 'pageview' AND path = {step1:String}, type = 'pageview' AND path = {step2:String}, type = 'custom' AND name = {step3:String}) AS level
FROM events
WHERE ts >= now() - INTERVAL {days:UInt32} DAY AND NOT is_bot
GROUP BY anonymous_id
) WHERE level > 0 GROUP BY level ORDER BY level
with "parameters": {"days": 30, "step1": "/", "step2": "/pricing", "step3": "signup"}. A visitor at level 2 reached steps 1 and 2; turn the levels into "reached at least step n" counts for the chart.
4. The page
One small server-side helper, for example:
async function capless(sql: string, parameters: Record<string, unknown> = {}) { const res = await fetch(`https://api.capless.sh/v1/materializations/${process.env.CAPLESS_MATERIALIZATION}/query`, { method: "POST", headers: { authorization: `Bearer ${process.env.CAPLESS_API_KEY}`, "content-type": "application/json" }, body: JSON.stringify({ sql, parameters }), }); const body = await res.json(); if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`); return body.rows as unknown[][]; // in the order of body.columns }Store the materialization id in
CAPLESS_MATERIALIZATIONnext to the key, and cache results for about 60 seconds (your framework's fetch cache, or a small in-memory map).Charts: use the chart library the app already has; otherwise plain SVG bars and lines are enough. Don't add a heavy dependency for a few charts.
Show the time range, a "last updated" time, and empty states ("no custom events yet: add
capless(\"track\", \"signup\")").Link rows to session replays where it helps:
GET /v1/replays?userId=…(oranonymousId) lists recordings; the user watches them in the Capless app.When it works, tell the user where the page is, what each number means, and that new events show up within about a minute.
Without ClickHouse
For a quick look at a small site, POST /v1/streams/$web.events/read with a CEL filter and map (see Reading) works without a materialization, but it scans records on every request, so it gets slow beyond a few thousand events. Use the materialization for a dashboard.