If WORKTREE.md exists in the repo root, read it before anything else. The tooling
that created this worktree generates it: URLs, ports, database names, the seeded login
and client secrets, where the process logs are and how to restart a process. Never guess
a URL or port — take them from WORKTREE.md or from your shell env ($DASHBOARD_URL,
$API_URL, $DATABASE_URL, $CLICKHOUSE_URL, $REDIS_URL).
Backend conventions (module layout, procedure builders, queues, tests) are the
contract in packages/core/AGENTS.md. Read it before touching packages/core.
Open-source product analytics (a Mixpanel alternative). Bun monorepo.
| Path | What | Stack |
|---|---|---|
packages/core |
The backend: every module, the tRPC router, Elysia routes, BullMQ jobs, ingest | TypeScript |
apps/api |
Thin shell that mounts core; ROLE=api|worker|all picks what one process runs |
Elysia on Bun |
apps/start |
The dashboard | TanStack Start, React 19, Redux Toolkit, Tailwind v4, Recharts |
packages/db |
Prisma (Postgres) + ClickHouse client and the sql tag (src/clickhouse/sql.ts) |
|
packages/seed |
Deterministic seed data for Postgres + ClickHouse (bun run seed) |
|
packages/redis, packages/email, packages/payments, packages/sdks/* |
Redis client/locks/pubsub, React Email, billing, client SDKs | |
apps/public |
Marketing + docs | Next.js, Fumadocs |
Data flow: SDK → POST /track (packages/core/src/modules/ingest) → Kafka → the
worker role → ClickHouse (events, sessions, profiles, materialized views).
Dashboard → tRPC ($API_URL/trpc) → ClickHouse for analytics, Postgres for config.
You are usually inside a worktree under .worktrees/<name> with its own port block,
its own Postgres/ClickHouse database (openpanel_<name>), Redis db index and Kafka
topics. WORKTREE.md says which, and whether the processes (web, api, studio) are
supervised for you.
- Supervised processes:
WORKTREE.mdgives the log files and the exact restart command. Prefer restarting over starting your own copy — two processes on one port is the usual cause of "my change does nothing". If the supervisor is gone, your shell has the same env it used, sobun run --filter @openpanel/api dev(orstart) starts a process on the right port.tail -fthe log while you test. - Env is injected into your shell and wins over
.env;.envin a worktree is a copy of the main checkout's and mostly irrelevant. - Never point at another worktree's database or edit files under
.worktrees/. - Migrations:
bun run --filter @openpanel/db migrate:deployruns both Prisma and the ClickHouse code migrations against your databases (already run when the worktree was created and seeded).
Without WORKTREE.md you are in a plain checkout: .env applies, bun run dock:up
starts Postgres/Redis/ClickHouse/Redpanda, and bun run dev starts api + dashboard.
The dashboard's server side runs under the Cloudflare Vite plugin, which reads
apps/start/.env.local (and wrangler vars) rather than your shell — set API_URL and
DASHBOARD_URL there, or export CLOUDFLARE_INCLUDE_PROCESS_ENV=true to make it use
the shell env.
bun run seed— seeds a user, organization, projects and clients into Postgres and realistic events/sessions/profiles into ClickHouse.--size small(default, ~14 days),medium,large,xl(tens of millions of events, for query-performance work), or--days N --sessions-per-day N.--resetwipes the seeded projects first. Same--seed⇒ identical data.--dry-runprints the row estimate..seed.json(gitignored, repo root) holds what was seeded: login, organization and project ids, client ids and secrets, and per project which funnels and breakdowns the data is built to show.WORKTREE.mdrepeats the important ones. The four projects (web, SaaS, shop, app) differ in identity: only identified projects have retention; seepackages/seed/README.md.- Direct database access:
bun run --filter @openpanel/db studio(Prisma Studio on$STUDIO_PORT), orpsql "$DATABASE_URL"/clickhouse-client/redis-cliwith the URLs from your env.
Verify like a user would, then like CI would. Pick the rows that match what you changed; a one-line fix does not need the full suite, a query or ingest change always does.
| You changed | Verify with | Also run |
|---|---|---|
Dashboard UI (apps/start) |
Open $DASHBOARD_URL with the Playwright MCP if you have one (browser_* tools; the local HTTPS certificate is self-signed, ignore that), log in with .seed.json, use the feature, take a screenshot (browser_take_screenshot; you get the image back — look at it) |
bunx vitest run in apps/start, then add/adjust a spec in apps/start/e2e/ and bun run e2e |
tRPC procedure / REST route (packages/core) |
Call it (recipes below) with seeded ids and check the JSON; for auth changes, try the wrong role too | bun test --isolate in packages/core for that module |
| ClickHouse query / chart / overview | Run old and new against the seeded data and diff results; check the numbers on the dashboard match a manual query | packages/core tests for the module; bun run --filter @openpanel/db test if you touched packages/db |
| Ingest / worker / buffers / sessions | bun run send journey --project acme-shop (live events through Kafka → worker → ClickHouse), then query events/sessions/profiles for that session id |
packages/core ingest + buffer tests, apps/api/e2e/ for session lifecycle |
MCP tools (packages/core/src/modules/mcp) |
Call the product's MCP against your API and data: as the openpanel MCP server if one is configured for you, else over HTTP (.seed.json → mcp.url, recipe below); compare its answer with a direct query |
packages/core mcp tests |
| Schema / migration | bun run --filter @openpanel/db migrate:deploy on your isolated database, then bun run seed --reset and the seed tests (bun run --filter @openpanel/seed test) |
bun run typecheck |
Anything shared (packages/shared, packages/db, config, deps) |
— | bun run typecheck and bun run test in full |
Always before you finish: bun run typecheck, and npx ultracite fix <files you changed>
(the repo is formatted and safe-fixed as of 2026-09-21, so a bare bun run fix is also
fine; bun run check still reports a few hundred lint errors that need judgment, so only
the files you touched have to come out clean). Report what you ran and what you saw;
a screenshot or a query result is evidence, "should work" is not.
Sending real events (bun run send, uses the seeded clients and $API_URL):
bun run send journey --project acme-shop --sessions 2 # replay a generated journey live
bun run send track purchase --project acme-shop --path /order/1 --prop value=99 --prop payment=card --revenue 99
bun run send identify usr_123 --project acme-saas --email a@b.co --first-name Ada --prop plan=proRequest recipes (ids and secrets from .seed.json):
# Track through the real pipeline
curl -sS -X POST "$API_URL/track" \
-H 'content-type: application/json' \
-H "openpanel-client-id: $CLIENT_ID" -H "openpanel-client-secret: $CLIENT_SECRET" \
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/128.0 Safari/537.36' \
-H 'x-client-ip: 81.2.69.142' \
-d '{"type":"track","payload":{"name":"screen_view","properties":{"__path":"/pricing"}}}'
# Management API (root client)
curl -sS "$API_URL/manage/projects" -H "openpanel-client-id: $ROOT_ID" -H "openpanel-client-secret: $ROOT_SECRET"
# tRPC as the seeded user: sign in, keep the `session` cookie, call any procedure
curl -sS -c cookies.txt -X POST "$API_URL/trpc/auth.signInEmail" -H 'content-type: application/json' \
-d '{"json":{"email":"admin@openpanel.local","password":"openpanel"}}'
curl -sS -b cookies.txt "$API_URL/trpc/organization.list"
# The product's MCP (streamable HTTP; url with token is `.seed.json` → mcp.url)
curl -sS -X POST "$MCP_URL" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Payload shapes for /track are in packages/core/src/modules/ingest/ingest.constants.ts.
apps/api/e2e/ is an HTTP harness for session-lifecycle stress tests.
Default: verify your own change yourself; it is small and you have the context. Spawn sub-agents only when the change touches several surfaces at once (API + dashboard + MCP, or a query used in many reports) — then one sub-agent per surface, in parallel, each with a concrete checklist ("open X, do Y, expect Z; report the numbers/screenshot"). They share your worktree and databases, so they must not migrate, reseed or restart processes; only you do that, before they start. Merge their reports into yours; do not forward them raw.
- A backend module is
packages/core/src/modules/<name>/with<name>.rpc.ts(tRPC),.routes.ts(REST),.jobs.ts(BullMQ),.service.ts,.constants.ts(zod schemas + constants, safe to import from the frontend).Cmd+P report.service. - Dashboard routes:
apps/start/src/routes/(TanStack file routing); modals insrc/modals/; tRPC client insrc/integrations/tanstack-query/. - ClickHouse queries use the
sqltag from@openpanel/db(packages/db/src/clickhouse/sql.ts). Table names:packages/db/src/clickhouse/client.ts(TABLE_NAMES). Schema: the numbered files inpackages/db/src/code-migrations/; a flat copy istest/clickhouse-schema.sql. - Env schema:
apps/api/src/config/env.ts. Auth:packages/core/src/modules/auth/. - The write path (what ends up in ClickHouse):
packages/core/src/modules/event/event.service.ts(createEvent),packages/core/src/buffers/session-buffer.ts,profile-buffer.ts.
Local is a single node; production is sharded (2 shards × 2 replicas, Distributed
tables). A plain IN (subquery) on a distributed table silently returns per-shard
results — use GLOBAL IN or a join. Never commit ClickHouse SQL you have not run
against your worktree's database; when replacing a query, diff old and new results
on the same data.
Formatting and linting is ultracite (Biome 2). Do not hand-format against it and do not
disable rules to make a check pass. Generated and vendored files are excluded in
biome.json; add to that list rather than reformatting build output.
- Constants over magic numbers: named, at the top of the file or in the module's
<name>.constants.ts. - Names reveal purpose; no abbreviations unless universal.
- Comments say why, briefly. Never narrate what the code does. If a function needs
a comment to explain what it does, split it. A comment that restates a rule from
this file, cites a document the repo does not contain, records where the code used
to live, or refers to V1 carries no knowledge — see
.claude/skills/clean-code. - Single responsibility, early returns, no nested ternaries, extract conditions into named booleans.
- DRY at the narrowest level that fits: the module first,
packages/core/src/shared/when several backend modules need it, a dedicated package when the frontend or SDKs need it too. Do not invent a cross-module abstraction for two call sites. unknownoverany;as const; narrowing over assertions;for…ofoverforEach.- React: function components, hooks at top level,
keyfrom ids, ref as a prop (React 19), semantic elements with labels and keyboard handlers. - No
console.log/debuggerleft behind. ThrowErrorobjects.
Leave the code you touch cleaner than you found it — inside the task's scope only. Do not refactor, upgrade dependencies or fix unrelated things you notice; mention them instead.
One branch per worktree, created from rewrite/v2. Commit when a step is done and
verified, with a message that says what and why. Never push, never add
remotes, never rewrite history that is not yours. WORKTREE.md, .seed.json and
.worktree-* are gitignored — keep them that way.
Every push to main is versioned from its commit messages, and the release
notes self-hosters read are written from them (tooling/release). So a commit
message is also a changelog entry and a version decision. The same rules apply
to PR titles, which become the commit on a squash merge.
<type>(<scope>): <what changed, from the user's side>
<why, and what it means for someone running OpenPanel>
- type decides the version bump:
feat— something users can do that they could not before. Bumps the minor version (3.1.4 → 3.2.0), so use it only for real new capability.fix— a bug users could hit.perf,refactor,docs,test,ci,chorefor the rest. All of these are a patch (3.1.4 → 3.1.5).
- scope is the area:
api,dashboard,ingest,self-hosting,sdk, … Optional, but it helps the notes group changes. - Subject: imperative, lower case, no trailing period, under ~72 characters. Say what changed for a user ("funnels can break down by cohort"), not which file was edited.
- Body: why, in plain sentences. If the change affects a self-hosted
install, say so explicitly: a new or renamed environment variable, a compose
or migration change, a removed feature, anything someone must do when they
upgrade. That is what the release notes' "Upgrade notes" are built from; add
the matching
openpanel doctorcheck in the same change (self-hosting/cli). - One logical change per commit. No "WIP", "fixes", or "address review".
- A commit that only touches
apps/public(the marketing site) never bumps the version and is left out of the release notes (tooling/release/src/release-scope.ts). Keep site changes in their own commits and PRs so they stay out of the product's releases.
Agents never mark a commit as breaking. Do not write ! after the type
(feat!:), a BREAKING CHANGE: footer, or [major], even when the change
does break something. A new major version is the maintainer's release
decision, not a side effect of one commit (the version tooling ignores the
first two anyway). Describe the breakage in the body instead, and point it out
in your summary so the maintainer can decide. Agents also do not use the other
CI markers: [minor] (let feat decide) and [release now] (skips the
merge debounce).