Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .claude/skills/canvas-templates/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
---
name: canvas-templates
description: How PostHog "canvas" dashboards work end-to-end — the two rendering tiers (json-render vs freeform React-in-iframe), the agent system prompts that steer each, and the RIGHT way to fetch PostHog data (typed query nodes through ph.query, not hand-rolled HogQL). Use when changing canvas templates, the freeform sandbox, the ph.* data shim, canvas data fetching, or the agent prompts that build dashboards / web-analytics boards.
---

# Canvas templates & data

PostHog "canvases" are agent-built dashboards/apps. There are **two rendering
tiers** and a strict **data path**. Get the tier and the data path right and most
canvas work is straightforward; get them wrong and you ship correctness bugs.

## The two tiers

A canvas's `kind` (set at create time, persisted in file meta) decides everything:

| Tier | `kind` | What the agent writes | Renderer | Data path |
| --- | --- | --- | --- | --- |
| **json-render** | `"json-render"` | JSONL patches against a component catalog | `ViewRenderer` (Quill component tree) | `state.queries` HogQL, re-run by `dashboardsService` on refresh |
| **freeform / React** | `"freeform"` | a single-file React app | sandboxed `<iframe>` (`FreeformCanvas`) | the `ph.*` shim → host → PostHog |

Which template maps to which tier: `REACT_TIER_TEMPLATE_IDS` in
`packages/core/src/canvas/freeformSchemas.ts`. Today `dashboard`, `web-analytics`,
and the generic `freeform` template render React; everything else is json-render.
Legacy canvases created before a template moved tiers keep their stored `kind` —
**there is no migration**, so both renderers must keep working.

Key files:
- `packages/core/src/canvas/canvasTemplates.ts` — the agent **system prompts** +
per-template rules. This is where you steer agent behavior. Two prompt families:
- `BASE_RULES` + `DASHBOARD_RULES` / `WEB_ANALYTICS_RULES` → json-render (catalog-built).
- `FREEFORM_BASE` + `buildFreeformPrompt(...)` → React tier. `freeformSystemPromptFor(id)`
picks the prompt for a `kind:"freeform"` canvas by templateId.
- `packages/core/src/canvas/canvasDataService.ts` — host-side `ph.query` / `ph.capture`.
- `packages/ui/src/features/canvas/freeform/` — the iframe: `FreeformCanvas.tsx`
(postMessage broker), `sandboxRuntime.ts` (the iframe HTML + the `ph` shim),
`freeformDataBridge.ts` (routes a `ph.*` call to the host tRPC).

## Data: the RIGHT way (read this before touching queries)

> **Reuse PostHog's query runners; don't reinvent metrics in SQL.**

The freeform app talks to PostHog ONLY through the injected `ph` global — the host
holds the token, the iframe never sees it. The one call that matters:

```js
const { columns, results } = await ph.query(arg)
```

`arg` is **either** a typed query node **or** an inline HogQL string:

- **PREFERRED — a typed query node:** `ph.query({ kind: "TrendsQuery", series: [...], dateRange: {...} })`.
The product's OWN query runners compute it, so the numbers **match the PostHog
UI exactly** (sessionization, unique users, breakdowns, math, bounce rate) and
the node's `dateRange` handles the window. The agent gets the node by creating/
opening an insight via the PostHog MCP tools and copying its `query` node.
- **ESCAPE HATCH — inline HogQL:** `ph.query("SELECT …")`. Only for shapes a typed
node can't express. The agent owns the SQL and its correctness.

Why this split exists: hand-rolled HogQL for standard metrics (especially web
analytics — bounce rate, channel attribution, sessionization) subtly diverges from
the product's numbers. Typed nodes are the same wheel the UI uses; don't re-cut it.

> **⚠️ The result SHAPE differs by kind — get it wrong and every value reads 0.**
> - HogQL → `{ columns: string[], results: rows[][] }` (read `results[row][col]`).
> - Typed node (TrendsQuery/etc.) → `results` is an array of **series objects**
> (`{ data: number[], days: string[], count, aggregated_value, compare_label, … }`),
> NOT rows. KPI total = `results[0].count`/`.aggregated_value`; series =
> `results[0].data`; the `compareFilter` previous period is a second series
> (match `compare_label === "previous"`, don't assume index order).
> `CanvasDataService.query` passes typed-node results through untouched and only
> row-coerces HogQL — see the `isTyped` branch. The first build of this missed it
> and rendered all-zeros despite the query running fine.

### The data path end-to-end

```
ph.query(arg) iframe (sandboxRuntime.ts shim)
└─ postMessage "data-request"
└─ FreeformCanvas route() ui (FreeformCanvas.tsx)
└─ handleFreeformDataRequest("query") ui (freeformDataBridge.ts)
└─ tRPC canvasData.query host (canvas-data.router.ts)
└─ CanvasDataService.query core (canvasDataService.ts)
└─ runQuery(node) core (posthogApi.ts)
└─ POST /api/projects/<id>/query/
{ query: <node>, refresh: "blocking" }
```

- `runQuery(authService, node, { refresh })` is the one place that POSTs to the
query endpoint. `runHogQLQuery(...)` is a thin wrapper that boxes a string into
`{ kind: "HogQLQuery", query }`. Both live in `posthogApi.ts`.
- `refresh: "blocking"` = the cached avenue (serve a fresh cached result, else
compute). Same cache insights use — so typed nodes are cached, not recomputed.
- `canvasDataQueryInput` (`freeformSchemas.ts`) accepts `{ query?, hogql?, params? }`
and refines that exactly one of `query` / `hogql` is present.

To add a new `ph.*` capability: add the method to the shim (`sandboxRuntime.ts`
`window.ph`), route it in `freeformDataBridge.ts`, add a tRPC procedure
(`canvas-data.router.ts`) backed by a `CanvasDataService` method. Never let the
iframe hold a token — it posts a request; the host runs the authenticated call.

> **`ph.run(insightShortId)` is stubbed** (`freeformDataBridge.ts` throws). It's
> the *view/published* tier's model: a shared canvas can't ship inline queries to
> anonymous viewers, so publish converts validated query nodes → saved insights +
> an allowlist and the canvas references them by id. Implement it there, not in edit.

## Dates

The freeform app owns its date control (the toolbar picker is hidden for freeform —
it drove json-render `state.queries`, see `WebsiteLayout.tsx`). The agent renders
Quill's `DateTimePicker` and feeds the window into the typed node's `dateRange`
(`{ date_from, date_to }`) — the runner handles timezone/bucketing/half-open.

> **`DateTimePicker` must be `compact` in the canvas.** Without the `compact`
> prop it auto-detects layout via `useMediaQuery('(min-width: 64rem)')` against the
> **iframe viewport** — which is full-width, so it picks the wide dual-calendar
> layout and overflows the popover it's anchored in. The prompt forces `compact`. The
inline-HogQL fallback must use half-open `timestamp >= toDateTime(fromUnix) AND
timestamp < toDateTime(toUnix)` (integer unix = UTC), never `now()` / `INTERVAL` /
inclusive `<= to`. These rules live in `FREEFORM_DATE_CONTROL_RULES`.

## Styling (freeform sandbox)

The iframe loads Quill's compiled CSS + tokens AND the Tailwind Play CDN
(`sandboxRuntime.ts`), with **Preflight disabled** (its unlayered form reset
overrode Quill's `@layer components` styles). So in a freeform canvas: build from
`@posthog/quill` components, Tailwind utilities work, and you do NOT restyle Quill
components. Allowed imports are the `FREEFORM_WHITELIST` (`freeformWhitelist.ts`);
the Quill version is `QUILL_VERSION` there (must match the CSS `<link>` URLs).

## Editing the agent prompts

- Steer the React data templates by editing the rule arrays in `canvasTemplates.ts`
(`FREEFORM_QUILL_RULES`, `FREEFORM_DATE_CONTROL_RULES`, `FREEFORM_DASHBOARD_RULES`,
`FREEFORM_WEB_ANALYTICS_RULES`). Generic `freeform` stays rule-free ("anything goes").
- The agent can't WebFetch at runtime (denied tool) — prompt rules must be
self-contained; URLs are knowledge pointers only.
- Prompt strings are plain TS array entries; biome lints the file. Avoid `${...}`
inside a normal string literal (biome flags it as a template placeholder).

## Checks after any change

```bash
pnpm --filter @posthog/core typecheck && pnpm --filter @posthog/core test -- --run
pnpm --filter @posthog/ui typecheck
npx biome lint packages/core/src/canvas packages/ui/src/features/canvas
```

Then **verify in the running app** — most of this tier (sandbox styling, the data
path, the date picker, refresh) is not covered by unit tests. Use the
`test-electron-app` skill to drive a real canvas over CDP.
8 changes: 0 additions & 8 deletions apps/code/src/main/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ import type {
AUTH_TOKEN_CIPHER,
AUTH_TOKEN_OVERRIDE,
} from "@posthog/core/auth/identifiers";
import type {
CANVAS_GEN_SERVICE,
FREEFORM_GEN_SERVICE,
} from "@posthog/core/canvas/identifiers";
import type {
CLOUD_TASK_AUTH,
ICloudTaskAuth,
Expand Down Expand Up @@ -99,8 +95,6 @@ import type {
GIT_PR_STATUS_PROVIDER,
IGitPrStatus,
} from "@posthog/host-router/ports/git-pr-status";
import type { CanvasGenService } from "@posthog/host-router/services/canvas-gen.service";
import type { FreeformGenService } from "@posthog/host-router/services/freeform-gen.service";
import type {
ANALYTICS_SERVICE,
IAnalytics,
Expand Down Expand Up @@ -431,8 +425,6 @@ export interface MainBindings {
[LOGS_SERVICE]: ILogsService;
[MAIN_ENCRYPTION_SERVICE]: EncryptionService;
[MAIN_DISCORD_PRESENCE_SERVICE]: DiscordPresenceService;
[CANVAS_GEN_SERVICE]: CanvasGenService;
[FREEFORM_GEN_SERVICE]: FreeformGenService;

// ws-server git service (bound to(GitService))
[WS_GIT_SERVICE]: GitService;
Expand Down
14 changes: 2 additions & 12 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@ import {
AUTH_TOKEN_OVERRIDE,
} from "@posthog/core/auth/identifiers";
import { canvasCoreModule } from "@posthog/core/canvas/canvas.module";
import {
CANVAS_GEN_SERVICE,
FREEFORM_GEN_SERVICE,
} from "@posthog/core/canvas/identifiers";
import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module";
import {
CLOUD_TASK_AUTH,
Expand Down Expand Up @@ -94,8 +90,6 @@ import {
GIT_PR_STATUS_PROVIDER,
type IGitPrStatus,
} from "@posthog/host-router/ports/git-pr-status";
import { CanvasGenService } from "@posthog/host-router/services/canvas-gen.service";
import { FreeformGenService } from "@posthog/host-router/services/freeform-gen.service";
import { ANALYTICS_SERVICE } from "@posthog/platform/analytics";
import { APP_LIFECYCLE_SERVICE } from "@posthog/platform/app-lifecycle";
import { APP_META_SERVICE } from "@posthog/platform/app-meta";
Expand Down Expand Up @@ -708,10 +702,6 @@ container.bind(MAIN_ENCRYPTION_SERVICE).to(EncryptionService);
container.bind(MAIN_TOKENS.DiscordPresenceService).to(DiscordPresenceService);

// Canvas / dashboards (project-bluebird). The host-agnostic dashboard services
// live in @posthog/core (bound via canvasCoreModule); CanvasGenService is the
// desktop-bound agent surface (a singleton holding per-thread agent state + a
// forwarding loop for app life). Both resolve through ctx.container in the
// host-router routers.
// live in @posthog/core (bound via canvasCoreModule) and resolve through
// ctx.container in the host-router routers.
container.load(canvasCoreModule);
container.bind(CANVAS_GEN_SERVICE).to(CanvasGenService).inSingletonScope();
container.bind(FREEFORM_GEN_SERVICE).to(FreeformGenService).inSingletonScope();
4 changes: 0 additions & 4 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { analyticsRouter } from "@posthog/host-router/routers/analytics.router";
import { archiveRouter } from "@posthog/host-router/routers/archive.router";
import { authRouter } from "@posthog/host-router/routers/auth.router";
import { canvasDataRouter } from "@posthog/host-router/routers/canvas-data.router";
import { canvasGenRouter } from "@posthog/host-router/routers/canvas-gen.router";
import { canvasTemplatesRouter } from "@posthog/host-router/routers/canvas-templates.router";
import { channelTasksRouter } from "@posthog/host-router/routers/channel-tasks.router";
import { cloudTaskRouter } from "@posthog/host-router/routers/cloud-task.router";
Expand All @@ -18,7 +17,6 @@ import { externalAppsRouter } from "@posthog/host-router/routers/external-apps.r
import { fileWatcherRouter } from "@posthog/host-router/routers/file-watcher.router";
import { focusRouter } from "@posthog/host-router/routers/focus.router";
import { foldersRouter } from "@posthog/host-router/routers/folders.router";
import { freeformGenRouter } from "@posthog/host-router/routers/freeform-gen.router";
import { fsRouter } from "@posthog/host-router/routers/fs.router";
import { gitRouter } from "@posthog/host-router/routers/git.router";
import { githubIntegrationRouter } from "@posthog/host-router/routers/github-integration.router";
Expand Down Expand Up @@ -55,7 +53,6 @@ export const trpcRouter = router({
archive: archiveRouter,
auth: authRouter,
canvasData: canvasDataRouter,
canvasGen: canvasGenRouter,
canvasTemplates: canvasTemplatesRouter,
channelTasks: channelTasksRouter,
dashboards: dashboardsRouter,
Expand All @@ -70,7 +67,6 @@ export const trpcRouter = router({
fileWatcher: fileWatcherRouter,
focus: focusRouter,
folders: foldersRouter,
freeformGen: freeformGenRouter,
fs: fsRouter,
git: gitRouter,
githubIntegration: githubIntegrationRouter,
Expand Down
22 changes: 10 additions & 12 deletions docs/canvas-freeform-react-plan.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
# Freeform React Canvases — Design Plan

> Status: design agreed via grilling session (2026-06-18). Not yet implemented.
> Scope: replace the **Blank** canvas template with agent-authored freeform React,
> executed in a sandboxed iframe, shareable externally without leaking credentials.
> Status: edit-tier shipped; publish/sharing tiers (below) scoped, not yet built.
> Scope: canvases are agent-authored freeform React, executed in a sandboxed
> iframe, shareable externally without leaking credentials.

## Summary

Today a canvas is a declarative **json-render** spec (JSONL patches → flat element
map → React component tree via `@json-render/react`, no iframe). This plan keeps
**Dashboard** and **Web Analytics** on json-render and replaces only **Blank** with
freeform React: the user talks to an agent, the agent writes a single React file
(JSX, runtime — no user-managed build step), and we render it in a sandboxed iframe.
Canvases are shareable with external people via a unique URL, with PostHog
credentials never present in the iframe.
A canvas is a freeform React app: the user talks to an agent, the agent writes a
single React file (JSX, runtime — no user-managed build step), and we render it in
a sandboxed iframe. Canvases are shareable with external people via a unique URL,
with PostHog credentials never present in the iframe.

Driven by all four motivations surfaced in grilling: arbitrary interactivity,
external shareability, easier/more reliable agent authoring, and the catalog's
Expand All @@ -39,8 +36,9 @@ provide the data avenue that internally handles caching and cold-boot issues;
them by reference. The insight layer owns caching + cold-boot, which also makes the
live public proxy cheap and resilient (external views hit cached insight results,
not cold queries).
- This replaces the current `dashboardQueryService` pattern (which runs HogQL against
`/api/projects/{id}/query/`) for freeform canvases.
- Freeform canvases query through `canvasDataService` (`ph.query` → host →
`/api/projects/{id}/query/`, cached `refresh: "blocking"`). The named-insight
avenue above is the planned public-tier model (see the two-tier security model).

---

Expand Down
9 changes: 2 additions & 7 deletions packages/core/src/canvas/canvas.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,22 @@ import { ContainerModule } from "inversify";
import { CanvasDataService } from "./canvasDataService";
import { CanvasTemplatesService } from "./canvasTemplatesService";
import { ChannelTasksService } from "./channelTasksService";
import { DashboardQueryService } from "./dashboardQueryService";
import { DashboardsService } from "./dashboardsService";
import { DESKTOP_FS_CLIENT, DesktopFsClient } from "./desktopFsClient";
import {
CANVAS_DATA_SERVICE,
CANVAS_TEMPLATES_SERVICE,
CHANNEL_TASKS_SERVICE,
DASHBOARD_QUERY_SERVICE,
DASHBOARDS_SERVICE,
} from "./identifiers";

// Host-agnostic canvas services (dashboards + their HogQL refresh). They only
// Host-agnostic canvas services (dashboards + freeform canvas data). They only
// need AuthService + fetch, so they live in @posthog/core and any host (desktop,
// web, server) can bind them by loading this module.
export const canvasCoreModule = new ContainerModule(({ bind }) => {
bind(DesktopFsClient).toSelf().inSingletonScope();
bind(DESKTOP_FS_CLIENT).toService(DesktopFsClient);

bind(DashboardQueryService).toSelf().inSingletonScope();
bind(DASHBOARD_QUERY_SERVICE).toService(DashboardQueryService);

bind(CanvasDataService).toSelf().inSingletonScope();
bind(CANVAS_DATA_SERVICE).toService(CanvasDataService);

Expand All @@ -33,7 +28,7 @@ export const canvasCoreModule = new ContainerModule(({ bind }) => {
bind(CHANNEL_TASKS_SERVICE).toService(ChannelTasksService);

// Canvas templates: host-agnostic (pure prompt strings), no deps. The
// host-router canvas-templates router and CanvasGenService resolve it by token.
// host-router canvas-templates router resolves it by token.
bind(CanvasTemplatesService).toSelf().inSingletonScope();
bind(CANVAS_TEMPLATES_SERVICE).toService(CanvasTemplatesService);
});
27 changes: 18 additions & 9 deletions packages/core/src/canvas/canvasDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
CanvasDataQueryInput,
CanvasDataResult,
} from "./freeformSchemas";
import { fetchCurrentUser, runHogQLQuery } from "./posthogApi";
import { fetchCurrentUser, runQuery } from "./posthogApi";

// Last-resort attribution if we can't resolve the signed-in user (and the
// canvas didn't pass its own distinctId).
Expand Down Expand Up @@ -54,16 +54,25 @@ export class CanvasDataService {

async query(input: CanvasDataQueryInput): Promise<CanvasDataResult> {
try {
// Cache-first execution (the insights avenue): serve a fresh cached
// result if present, otherwise compute it now.
const { columns, results } = await runHogQLQuery(
this.authService,
input.hogql,
{ refresh: "blocking" },
);
// A typed query node (TrendsQuery/etc.) runs as-is so the numbers match the
// PostHog UI; an inline HogQL string is the escape hatch. Cache-first
// execution (the insights avenue): serve a fresh cached result if present,
// otherwise compute it now.
const isTyped = input.query != null;
const node = isTyped
? (input.query as Record<string, unknown>)
: { kind: "HogQLQuery", query: input.hogql as string };
const { columns, results } = await runQuery(this.authService, node, {
refresh: "blocking",
});
return {
columns,
results: results.map((r) => (Array.isArray(r) ? r : [r])),
// HogQL returns rows; normalise a bare scalar row to a 1-cell array.
// Typed nodes return SERIES OBJECTS — pass them through untouched (wrapping
// them in arrays is what made every value read as 0).
results: isTyped
? results
: results.map((r) => (Array.isArray(r) ? r : [r])),
};
} catch (err) {
this.log.warn("Canvas query failed", {
Expand Down
Loading
Loading