/canvas-templates
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
$ npx -y skills add posthog/posthog --skill canvas-templates --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/canvas-templates
Context preview
The summary Claude sees to decide when to auto-load this skill.
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
SKILL.md
canvas-templates.SKILL.mdname: 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:
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** (`freeformD
Read more
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:
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** (`freeformD
:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.
Repo: posthog/posthog
Other skills on posthog.
- /analyzing-expensive-users
Analyze the most expensive users in AI observability and explain why they cost so much. Use when the user asks about top spenders, expensive users, per-user LLM cost, user-level cost drivers, or patterns behind high AI observability spend.
Open skill - /creating-online-evaluations
Author continuously-running online evaluations in PostHog AI observability, grounded in real failure modes you've identified. Use when the user wants evaluations that automatically score new generations or whole traces going forward — "create an eval to catch X", "continuously
Open skill - /exploring-ai-failures
Find where an AI/LLM application is failing in production and surface the failure patterns, working from real traces. Use when someone wants to understand what's going wrong with an AI feature, find and categorize failure modes, triage errors, or investigate quality issues
Open skill - /exploring-llm-clusters
Investigate AI observability clusters — understand usage patterns in AI/LLM traffic, compare cluster behavior, compute cost/latency metrics, and drill into individual traces within clusters.
Open skill - /exploring-llm-costs
Investigate LLM spend in PostHog — total cost over time, cost by model, provider, user, trace, or custom dimension, token and cache-hit economics, and cost regressions. Use when the user asks "how much are we spending on LLMs?", "which model / user / feature is most expensive?",
Open skill - /exploring-llm-evaluations
Investigate AI observability evaluations — `hog` (deterministic code-based), `llm_judge` (LLM-prompt-based), and `sentiment` (user-message sentiment). Find existing evaluations, inspect their configuration, run them against specific generations, query individual results, and
Open skill

