--- 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 `
` 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:` 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///pull/` 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.