Get PostHog data into a canvas correctly: the host-injected `ph` SDK (loadInsight, query, capture, state, connectors, openExternal, navigate), the data hierarchy (saved insights first, typed query nodes second, inline HogQL last), verifiability (insight-backed metrics link their saved insight in PostHog; ad-hoc queries expose the exact query that ran), per-insight-type result shapes, progressive per-query loading, date-range wiring, live third-party data through the viewer's own connections (...
Installs into .claude/skills of the current project.
Are you the author of Querying Canvas Data?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/posthog-querying-canvas-data-posthog)
---
name: querying-canvas-data
description: >
Get PostHog data into a canvas correctly: the host-injected `ph` SDK (loadInsight, query,
capture, state, connectors, openExternal, navigate), the data hierarchy (saved insights first, typed query nodes
second, inline HogQL last), verifiability (insight-backed metrics link their saved insight in
PostHog; ad-hoc queries expose the exact query that ran), per-insight-type result shapes,
progressive per-query loading, date-range wiring, live third-party data through the viewer's own
connections (ph.connectors), and event capture from a canvas. Use whenever a canvas shows
metrics, charts, tables, any PostHog data, or data from GitHub or an MCP server, or needs to send
analytics events.
---
# Querying canvas data
The `ph` bridge is the only way a canvas talks to PostHog. Import it with
`import { ph } from "@posthog/canvas-sdk"` — a platform-provided module, so it needs no
`dependencies` entry. The same object is also installed as the `window.ph` global, which existing
canvases use; prefer the import in new code. Its typed surface is
[references/canvas-sdk.d.ts](references/canvas-sdk.d.ts). Never initialize it: credentials stay in
the host, and `fetch()`, posthog-js, and hand-rolled clients cannot reach PostHog from the sandbox.
External requests and resources require a non-PostHog origin declared in
`capabilities.network.origins`, and work only in the published canvas — the edit-mode preview
blocks all direct network access. This includes external stylesheets; remote scripts remain
blocked.
## Data hierarchy — back every metric with a saved insight
1. **Preferred — save an insight, load it by reference.** Use the PostHog MCP insight tools to
create/save an insight that computes the metric with an insight query type (TrendsQuery,
FunnelsQuery, RetentionQuery, PathsQuery, or the web-analytics kinds WebOverviewQuery /
WebStatsTableQuery — not raw SQL). Confirm its numbers, note the `short_id`, and render it with
`await ph.loadInsight(shortId, { dateRange })`. These are proven queries — numbers match the
PostHog UI exactly (sessionization, unique users, breakdowns, bounce rate). Never fabricate a
query or guess event/property names; discover and save them via MCP first.
2. **Secondary — an ad-hoc typed node**: `ph.query({ kind: "TrendsQuery", series: [...], dateRange: {...} })`
when saving an insight genuinely doesn't fit.
3. **Last resort — inline HogQL**: `ph.query("SELECT …")`, only when no insight kind can express
the metric; you then own the SQL and its date window.
For web-analytics boards specifically, use the web-analytics query kinds — raw HogQL subtly gets
bounce rate, sessionization, channel attribution, and unique-visitor counts wrong.
Whatever tier you use, **declare it in the project's `capabilities`** before publishing: every
`ph.loadInsight` short id in `capabilities.posthog.insights`, every `ph.capture` event name in
`captureEvents`, and `inlineQueries: true` for any `ph.query` use. The host rejects undeclared
calls at runtime, and validation fails on undeclared literals.
## Verifiability — every claim must be checkable in PostHog
A number a viewer cannot verify is a number they cannot trust. Every data-backed figure a canvas
shows — a KPI, a chart, a table, a stated conclusion — must carry the verification affordance for
its tier:
1. **Insight-backed metrics link the real insight in PostHog.** For a metric loaded from a saved
insight (the preferred tier), render a "View in PostHog" affordance that calls
`ph.openExternal(insightUrl)` from a click. Mint the URL at authoring time with the
`generate-app-url` MCP tool (path template `/insights/{id}` with the insight's short id) and
bake the returned URL into the source verbatim — never hand-build one. `ph.openExternal` only
opens `https://*.posthog.com` URLs and only from a user gesture, so wire it to a button or
link, never to load or render. Do not also bake the insight's saved query text into the
source: canvas source is readable by every canvas viewer, while access to the insight itself
is enforced by PostHog — the link is where a viewer inspects the query, with their own
permissions applied.
2. **Ad-hoc queries disclose the exact query that ran, viewable in place.** For a figure computed
by `ph.query` (a typed node or inline HogQL), show the query behind it — the HogQL text, or
the typed query node pretty-printed as JSON — in a modal or a collapsed disclosure attached to
the card (a Quill `Dialog` or `Collapsible` in a React canvas, a `<details>` element in an
HTML one). Render it from the same constant or builder you pass to `ph.query`, so the
displayed query can never drift from the executed one. This discloses nothing beyond what the
viewer already runs: `ph.query` executes as the signed-in viewer.
These are not optional polish: a canvas that presents PostHog data without them is incomplete.
Keep the affordances compact — a small link icon per insight-backed card, a "View query"
disclosure per ad-hoc card, or one shared modal listing every ad-hoc query the canvas runs, each
labeled with the figure it backs.
For a status board, set `refresh` to the cache lifetime in seconds. Use a whole number from 30 to
86400 (one day); values outside that range, or fractional ones, fail at runtime:
```js
await ph.loadInsight(shortId, { refresh: 30 })
await ph.query(queryNode, {}, { refresh: 30 })
```
## Result shapes — read them correctly or every value renders 0
- **Trends-style results** (insight query types, via `ph.loadInsight` or a typed node): `results`
is an array of **series objects**, not rows. Each series has `data: number[]` (per interval),
`days: string[]` (ISO), `labels: string[]`, `count` (sum), `aggregated_value` (single-value
total), `label`, and optional `compare_label: "current" | "previous"`. A KPI total is
`results[0].count` (or `.aggregated_value`); a line chart plots `results[0].data` over
`results[0].days`. `count` sums the per-interval values, which double-counts a unique-users
series (`math: "dau"`) for anyone active on several days — for a period-unique KPI, set
`trendsFilter: { display: "BoldNumber" }` on the query and read `aggregated_value` instead.
With a compare period, find the prior series by `compare_label === "previous"`
— never by index. `columns` is empty here.
- **SQL results**: `{ columns: string[], results: rows[][] }` — each row an array of cell values in
`columns` order.
## Load progressively — render each section when its own data lands
PostHog queries can take several seconds each, and a board usually runs several. Never gate
rendering on all of them:
- Fire independent queries concurrently on mount; never chain unrelated queries with sequential
`await`s. The host runs 8 data requests at a time and makes the rest wait in a 32-deep queue,
so a board with more sections than slots still loads, section by section. A board wide enough to
outlast the queue gets its extra requests refused, with the reason in the error message, and the
runtime sends each one again after a backoff before it gives up; a board that wide consolidates
its queries (one query returning every row, sliced client-side), still one state per section.
- Give every query its own `{ loading, error, data }` state and let each card, chart, or table
swap its skeleton for data the moment its own result arrives. One shared `loading` flag or a
single `Promise.all` across independent queries makes the fastest metric wait for the slowest —
the canvas must fill in progressively, not appear all at once.
- Render the static chrome (heading, date picker, card frames with skeletons inside) immediately;
only the value inside each section waits for its query.
- Defer queries the first paint doesn't need: content behind a tab, a collapsed section, or a
drill-down runs its query when the user reveals it, not on mount.
Load data in `useEffect` with `useState`, and aggregate in the query; never
fetch raw event dumps. Treat a rejected query and an empty result as different states: `.catch`
must set an error state that renders visibly (message + retry), never fall through to zeros, an
empty chart, or a "no data" message — a swallowed error makes real breakage (a missing table, an
auth failure) look like missing data. Reserve the empty state for a query that succeeded with no
rows.
## Date windows
- Pass the canvas's date-picker window straight into `dateRange`:
`ph.loadInsight(shortId, { dateRange: { date_from: win.start.toISOString(), date_to: win.end.toISOString() } })`
— the saved insight re-scopes to the window with no time SQL. Typed nodes take the same
`dateRange`. Re-run every query when the window changes.
- A saved **SQL** insight may ignore `dateRange` (its window lives inside the SQL) — a reason to
prefer insight query types. If its window comes from a `{variables.…}` placeholder, drive it
through `variables` (below) instead; `dateRange` will never reach it.
- Inline HogQL escape hatch only: never bake `now()` or a hardcoded INTERVAL. Compute unix bounds
(`Math.floor(win.start.getTime() / 1000)`) and write half-open
`timestamp >= toDateTime(fromUnix) AND timestamp < toDateTime(toUnix)`. Prior period = the
equal-length window immediately before; bucket with `toStartOfDay`/`toStartOfHour`.
## SQL variables
A saved SQL insight whose HogQL contains `{variables.name}` placeholders takes its values per call,
keyed by the variable's **code name** (not its uuid):
```js
await ph.loadInsight(shortId, { variables: { product: 'surveys', month: '2026-07-01' } })
```
This is how one saved insight fills a whole board — the same per-product insight loaded once per
product — rather than every tile resolving the insight's saved default.
- Read the code names off the insight's query first (`insight-get` over MCP). The host **rejects** a
variable the insight doesn't use, and rejects one whose value didn't take effect, instead of
silently falling back to the saved value — so a variable mismatch surfaces as a visible error, not
as another product's numbers.
- Variables are part of the read cache key, so N products means N loads. Prefer **one** insight
returning every product as rows over the same insight loaded N times, and slice it client-side.
- Values are typed by the variable's definition in PostHog (String / Number / Boolean / Date / List);
pass the same shape the insight expects, and an array for a multi-select List variable.
## Live Tasks data
For a task inbox, queue, or status board, query `system.tasks` and `system.task_runs` through
`ph.query`. Do not call `posthog:tasks-list` while authoring and embed its response: that produces a
snapshot, while the system tables keep the rendered canvas live.
The tables run as the signed-in viewer. They are project-scoped and require access to the Tasks
resource. `system.tasks` includes only non-internal tasks filed in live public spaces; it excludes
private, personal, unfiled, and internal tasks. Always exclude soft-deleted tasks explicitly.
Join a task to its latest run when the canvas needs current status:
```tsx
const data = await ph.query(`
SELECT
t.id,
t.task_number,
t.title,
t.repository,
t.created_by_id,
t.created_at,
t.updated_at,
latest.status AS latest_run_status
FROM system.tasks AS t
LEFT JOIN (
SELECT
task_id,
argMax(status, tuple(created_at, id)) AS status
FROM system.task_runs
GROUP BY task_id
) AS latest ON latest.task_id = t.id
WHERE t.deleted = 0
ORDER BY t.updated_at DESC
LIMIT 100
`)
```
This is inline HogQL, so declare `capabilities.posthog.inlineQueries: true`. Render links with
`ph.navigate.toTask(id)` rather than constructing task URLs.
Do not promise filters the tables cannot express. `channel_id` is not queryable, so a canvas cannot
currently restrict this query to its own space. Filtering to the current viewer also requires a
known numeric user id; the canvas runtime does not inject one. State these limits when the request
depends on “this space” or “my tasks” instead of silently showing project-wide public tasks.
## Runtime memory — ph.state
Durable key-value storage per canvas. Declare every scope you use in `capabilities.posthog.state`
(`["user"]`, `["shared"]`, or both) — undeclared scopes fail validation and the host refuses them
at runtime. Scope `"user"` (the default when no scope is passed) is private to each viewer;
`"shared"` is one value per canvas, visible to the whole team.
```tsx
const draft = await ph.state.get('draft') // user scope by default; null when unset
await ph.state.set('draft', { text }) // JSON value, capped at 64 KB serialized
await ph.state.set('draft', null) // null deletes the key
await ph.state.set('board', { columns }, { scope: 'shared' }) // team-visible
const entries = await ph.state.list({ scope: 'shared' }) // [{ scope, key, value, updatedAt }]
```
- Load state in an effect on mount and render a skeleton until it resolves; writes are
last-write-wins, so re-read (or trust your own write) rather than merging.
- 256 keys per scope. Store big data in PostHog (insights, the warehouse) and reference it.
- State is team-visible application data — never secrets, never viewer PII.
When a user asks about a canvas's current progress or settings, do not infer them from source alone.
Call `canvas-state-retrieve` with the canvas id after reading its source. It returns shared state plus
the authenticated user's own user-scoped state for canvases in public channels or their personal
channel. Use `canvas-state-set` when the user asks to change those values; read first, preserve
unrelated keys, and use the scope the canvas source expects.
Canvas discussions use the generic comment tools. Read them with `comments-list` filtered to
`scope=canvas` and the canvas id as `item_id`. Create a root comment or reply with
`comments-create`, using the same scope and item id. A thread belongs to the canvas, so a task id is
optional. Put one in `item_context.taskId` only when that task generated or published the canvas;
the API refuses any other task. The space of the canvas controls access: a user who can see the
space can read and write its comments.
## PostHog writes — ph.actions
`ph.actions.invoke(verb, payload)` writes into PostHog as the viewer. Declare every verb in
`capabilities.posthog.actions`; undeclared or unregistered verbs fail validation and the host
refuses them at runtime. Invocations must be wired to an explicit user gesture (a button the
viewer clicks) — the host rejects calls made on load or render.
Render the result or the thrown error visibly, and disable the button while the call is in
flight — every invocation is a real PostHog write.
The registry is the source of truth for verbs. Before wiring one, list it with the
`canvases-actions-retrieve` tool: each entry carries `verb`, `summary`, `destructive`, and
`usage` — the payload and result shape, what invoking it actually does, and the confirmation
copy it warrants. Follow a verb's `usage` exactly, including what the success message may claim.
Do not infer a verb's payload from the matching product's own MCP tools or skills — an MCP tool
call (you, now, with your credentials) and a canvas verb (the viewer, later, in the published
canvas) differ in payload shape, auth, and behavior. Invoking looks like:
```tsx
const { result } = await ph.actions.invoke('tasks.create', { title, description })
```
## Live third-party data — ph.connectors
`ph.connectors.call(provider, tool, args, { refresh? })` reads data from a third-party service
with the **viewer's** own connection, at view time. Use it for anything that must stay fresh
per person: open pull requests, today's meetings, assigned issues. Never fetch such data
yourself while authoring and bake the result into the source — that snapshot is stale the moment
it is published, and it shows every viewer the author's data.
- Providers are `github` (native, over the viewer's personal GitHub connection) or
`mcp:<server host>` for any server the viewer has connected in the MCP store (for example
`mcp:mcp.calendly.com`). Discover providers, tools, argument schemas, and per-tool `usage`
with the `canvas-connectors-retrieve` tool; pass `mcp_hosts` to inspect a server the current
user has not connected. Call only tools whose catalog entry has `is_read_only: true`.
MCP tools need an explicit read-only hint and a name that passes the local read-verb check.
- **Declare every provider and tool** in `capabilities.connectors` as
`[{ "provider": "github", "tools": ["list_pull_requests"] }]`. Validation rejects an
undeclared `ph.connectors.call` literal, and the host refuses undeclared calls at runtime.
- The call resolves to `{ status, result, detail, truncated, connect_path }`. Branch on `status`:
- `ok` — `result` holds the tool output. Native tools return their documented shape; MCP
tools return `{ content, structured_content, is_error }` (MCP content blocks).
- `not_connected` / `needs_reauth` — this viewer has no usable connection. Render a
"Connect GitHub" (or the server's name) button that calls `ph.connectors.connect(provider)`
from the click; the host opens the right settings page. Never treat this as empty data.
- `blocked`, `write_blocked`, `tool_missing`, `upstream_error` — show `detail` with a retry.
- `truncated: true` means the result exceeded 256 KB and was cut to a preview; narrow the call
(a smaller `limit`, one repository) instead of paging client-side.
- Calls can start on mount, but the host asks the viewer for access before it reads connector data.
Consent applies to one canvas version, provider, and tool. A refusal rejects the call; show the error and a retry button.
- Results are cached per canvas version for `refresh` seconds (default 60, range 30–86400).
Account, organization, and project changes clear both results and consent.
- Keep connector results in component state or `ph.state` scope `"user"`.
A canvas with connectors cannot declare shared state. Validation and the API reject this combination.
```tsx
const [prs, setPrs] = useState<{ loading: boolean; status?: string; rows?: PullRequest[] }>({ loading: true })
useEffect(() => {
ph.connectors
.call('github', 'list_pull_requests', { repository: 'example/app', state: 'open' }, { refresh: 60 })
.then((res) => setPrs({ loading: false, status: res.status, rows: res.result?.pull_requests ?? [] }))
.catch((error) => setPrs({ loading: false, status: 'error', rows: [] }))
}, [])
```
## Side effects
- `ph.capture(event, properties?, distinctId?)` — analytics events for interactions
(fire-and-forget). Session replay, `$session_id`, and person attribution are handled by the
host automatically; never initialize recording, set session ids, or roll your own capture.
- `ph.openExternal(url)` — opens PostHog HTTPS URLs and `https://github.com/<owner>/<repo>/pull/<number>`
links from a user click. GitHub PR links open without a confirmation dialog. Files, commits, checks,
and fragment links are allowed; credentials, custom ports, query strings, and other domains are not.
Sandboxed `target="_blank"` navigation is blocked, so use the bridge rather than a browser fallback.
- `ph.navigate.toTask(id)` / `.toNewTask()` / `.toCanvas(id)` / `.toNewCanvas()` — in-app
navigation within the canvas's own channel.
- `ph.navigate.toNewTask({ prompt, repository })` — opens a prefilled task form in a new tab from
a user click. `prompt` is at most 16,000 characters; `repository` is an `owner/repo` name. Both are
optional. Setting a repository selects cloud mode for this task without changing the space.
The viewer reviews and sends the prompt; opening the form does not start a run. Check for
`ph.navigate` in older published artifacts and ask for a rebuild after deployment if it is missing.