Skip to content
Back to skills

Querying Canvas Data

ASecurity

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 (...

  • 40,048 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 1, 2026
developmentrustsqlreactnodeexpressgitapi

Works with

  • cli
  • api
  • mcp

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 1, 2026

npx -y skills add PostHog/posthog --skill querying-canvas-data --agent claude-code

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.

Security grade badge for Querying Canvas Data
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/posthog-querying-canvas-data-posthog/badge)](https://www.skillsdirectory.com/skills/posthog-querying-canvas-data-posthog)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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.

Files in this skill

  • SKILL.md15.6 KB
  • references/canvas-sdk.d.ts5.9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.