# 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:

```text
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

1. **Ask first, briefly.** What should the dashboard answer? Which pages or events matter (signup, checkout, a `data-capless` button)? 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.
2. **Put it where the user already works.** If the project already has an admin panel or internal tool (an `/admin` area, 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 example `app/admin/analytics/page.tsx` in 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.
3. **Make it fast:** query a ClickHouse [materialization](https://analytics.capless.sh/docs/materializations.md) 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`).
4. **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.

```sh
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:

```sh
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`):

```sql
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:

```cel
{'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](https://analytics.capless.sh/docs/web-sdk.md#what-is-collected-automatically).

## 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:

```sql
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:

```sql
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):

```sql
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:

```sql
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", …)`):

```sql
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):

```sql
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:

  ```ts
  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_MATERIALIZATION` next 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=…` (or `anonymousId`) 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](https://analytics.capless.sh/docs/reading.md)) 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.
