
Canvas Templates
FreeStreamline PostHog canvas dashboard creation.
Free · Opens the source repo
What Canvas Templates does
The Canvas Templates skill provides a structured approach to building PostHog canvases, which are agent-generated dashboards and applications. Understanding the two rendering tiers—json-render and freeform React—is crucial for effective canvas development. Each tier has specific requirements for data handling and rendering, ensuring that developers can create functional and accurate dashboards without introducing bugs. The skill emphasizes the importance of using the correct data path, which is essential for maintaining the integrity of the analytics displayed on the canvas.
The json-render tier utilizes JSONL patches against a component catalog, while the freeform React tier allows for more flexibility by enabling developers to write a single-file React app. This skill guides users on how to properly set the canvas's kind at creation time, which determines the rendering approach and data path. It also includes detailed information on how to fetch data correctly using PostHog's query runners, ensuring that metrics are accurate and consistent with the PostHog UI.
For developers working with PostHog, this skill is particularly valuable when modifying canvas templates, utilizing the freeform sandbox, or managing the agent prompts that build dashboards. It provides clarity on the agent system prompts and the rules governing each template, which are critical for steering agent behavior effectively. By following the outlined best practices, users can avoid common pitfalls that lead to incorrect data representation and enhance their overall experience in creating data-driven applications.
In summary, the Canvas Templates skill is designed for developers and designers who want to harness the full potential of PostHog's canvas capabilities, ensuring that they build accurate and visually appealing dashboards while adhering to the recommended data handling practices.
When to use it
Use this skill when creating or modifying PostHog canvas templates and dashboards to ensure proper rendering and data accuracy.
When not to use it
This skill may not be suitable for users unfamiliar with React or those not working within the PostHog ecosystem.
What you can build with it
Creating a New Dashboard
Utilize the skill to set up a new PostHog dashboard, ensuring you select the appropriate rendering tier and data path.
Modifying Existing Templates
When updating existing canvas templates, refer to the skill for guidance on maintaining data integrity and rendering accuracy.
Building Complex Data Visualizations
Leverage the freeform React tier for advanced visualizations while adhering to the best practices outlined in the skill.
How to install Canvas Templates
View source1. Install with the skills CLI
npx skills add posthog/posthog/canvas-templates --agent claude-code2. Or install it manually
Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.
Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs
Inside SKILL.md
Written by posthogCanvas 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 akind:"freeform"canvas by templateId.
packages/core/src/canvas/canvasDataService.ts— host-sideph.query/ph.capture.packages/ui/src/features/canvas/freeform/— the iframe:FreeformCanvas.tsx(postMessage broker),sandboxRuntime.ts(the iframe HTML + thephshim),freeformDataBridge.ts(routes aph.*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'sdateRangehandles the window. The agent gets the node by creating/ opening an insight via the PostHog MCP tools and copying itsquerynode. - 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[][] }(readresults[row][col]).- Typed node (TrendsQuery/etc.) →
resultsis 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; thecompareFilterprevious period is a second series (matchcompare_label === "previous", don't assume index order).CanvasDataService.querypasses typed-node results through untouched and only row-coerces HogQL — see theisTypedbranch. 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 inposthogApi.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 ofquery/hogqlis 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.tsthrows). 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.
DateTimePickermust becompactin the canvas. Without thecompactprop it auto-detects layout viauseMediaQuery('(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 forcescompact. The inline-HogQL fallback must use half-opentimestamp >= toDateTime(fromUnix) AND timestamp < toDateTime(toUnix)(integer unix = UTC), nevernow()/INTERVAL/ inclusive<= to. These rules live inFREEFORM_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). Genericfreeformstays 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
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.
Frequently asked questions about Canvas Templates
Similar skills
Playwright Component Testing
Test React and Vue components in isolation with Playwright.
Fluent UI Blazor
Integrate Fluent UI components in Blazor applications effortlessly.
Build MCP App
Create interactive UI widgets for MCP servers.
Web Design Reviewer
Identify and fix design issues in websites efficiently.
Markstream Install
Seamlessly integrate Markstream for Markdown rendering.
GSAP & Framer Scroll Animation
Create advanced scroll animations effortlessly.
