Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
"@posthog/host-router": "workspace:*",
"@posthog/host-trpc": "workspace:*",
"@posthog/platform": "workspace:*",
"@posthog/quill": "0.3.0-beta.1",
"@posthog/quill": "catalog:",
"@posthog/shared": "workspace:*",
"@posthog/ui": "workspace:*",
"@posthog/workspace-client": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { analyticsRouter } from "@posthog/host-router/routers/analytics.router";
import { archiveRouter } from "@posthog/host-router/routers/archive.router";
import { authRouter } from "@posthog/host-router/routers/auth.router";
import { canvasGenRouter } from "@posthog/host-router/routers/canvas-gen.router";
import { canvasTemplatesRouter } from "@posthog/host-router/routers/canvas-templates.router";
import { cloudTaskRouter } from "@posthog/host-router/routers/cloud-task.router";
import { connectivityRouter } from "@posthog/host-router/routers/connectivity.router";
import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.router";
Expand Down Expand Up @@ -50,6 +51,7 @@ export const trpcRouter = router({
archive: archiveRouter,
auth: authRouter,
canvasGen: canvasGenRouter,
canvasTemplates: canvasTemplatesRouter,
dashboards: dashboardsRouter,
cloudTask: cloudTaskRouter,
connectivity: connectivityRouter,
Expand Down
3 changes: 2 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,8 @@
"!@posthog/workspace-client/client",
"!@posthog/platform",
"!@posthog/platform/*",
"!@posthog/quill"
"!@posthog/quill",
"!@posthog/quill-charts"
],
"message": "ui must run in any JS environment."
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"@posthog/cli"
],
"overrides": {
"zod": "4.3.6"
"zod": "4.3.6",
"@posthog/quill>@base-ui/react": "^1.3.0"
}
}
}
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"clean": "node ../../scripts/rimraf.mjs .turbo"
},
"dependencies": {
"@json-render/core": "^0.19.0",
"@modelcontextprotocol/ext-apps": "^1.1.2",
"@modelcontextprotocol/sdk": "^1.12.1",
"@pierre/diffs": "^1.1.21",
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/canvas/canvas.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { ContainerModule } from "inversify";
import { CanvasTemplatesService } from "./canvasTemplatesService";
import { DashboardQueryService } from "./dashboardQueryService";
import { DashboardsService } from "./dashboardsService";
import { DASHBOARD_QUERY_SERVICE, DASHBOARDS_SERVICE } from "./identifiers";
import {
CANVAS_TEMPLATES_SERVICE,
DASHBOARD_QUERY_SERVICE,
DASHBOARDS_SERVICE,
} from "./identifiers";

// Host-agnostic canvas services (dashboards + their HogQL refresh). They only
// need AuthService + fetch, so they live in @posthog/core and any host (desktop,
Expand All @@ -12,4 +17,9 @@ export const canvasCoreModule = new ContainerModule(({ bind }) => {

bind(DashboardsService).toSelf().inSingletonScope();
bind(DASHBOARDS_SERVICE).toService(DashboardsService);

// Canvas templates: host-agnostic (pure prompt strings), no deps. The
// host-router canvas-templates router and CanvasGenService resolve it by token.
bind(CanvasTemplatesService).toSelf().inSingletonScope();
bind(CANVAS_TEMPLATES_SERVICE).toService(CanvasTemplatesService);
});
84 changes: 84 additions & 0 deletions packages/core/src/canvas/canvasSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { defineSchema } from "@json-render/core";

// Core-only mirror of `@json-render/react`'s element-tree `schema`. That schema
// is itself pure `@json-render/core` (no React runtime), but it's only reachable
// via the React package's entrypoint — importing it would drag React into
// @posthog/core (forbidden: core must stay host-agnostic). So we re-declare the
// identical schema here, in core, where BOTH the main-process services (to
// generate the agent system prompt via `catalog.prompt()`) and the renderer can
// use it.
//
// IMPORTANT: keep this in lockstep with `@json-render/react`'s `schema`
// (pinned at @json-render/* 0.19.0). If that package's schema changes, mirror it.
export const canvasSchema = defineSchema(
(s) => ({
// What the AI-generated SPEC looks like.
spec: s.object({
root: s.string(),
// Optional initial state model the UI reads/writes (form fields, toggles).
state: s.any(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
visible: s.any(),
// Event → action bindings (e.g. { "click": { "action": "setState", … } }).
on: s.any(),
}),
),
}),
// What the CATALOG must provide.
catalog: s.object({
components: s.map({
props: s.zod(),
slots: s.array(s.string()),
description: s.string(),
example: s.any(),
}),
actions: s.map({
params: s.zod(),
description: s.string(),
}),
}),
}),
{
builtInActions: [
{
name: "setState",
description:
"Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
},
{
name: "pushState",
description:
'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
},
{
name: "removeState",
description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
},
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
},
],
// NOTE: the canvas renderer now resolves json-render's DECLARATIVE dynamic
// features — a top-level `state` model, `{$state}` reads, `{$bindState}`
// two-way form bindings, `visible` conditions, and `on`/actions (the four
// built-ins: setState/pushState/removeState/validateForm). It does NOT yet
// resolve `repeat`/`{$item}`/`{$index}`; those still degrade to empty. We
// drop the upstream default rules (which assume the full feature set) and
// keep only the always-applicable guidance; the per-template rules
// (canvasTemplates.ts) spell out exactly which dynamic features are allowed.
defaultRules: [
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
"Design with visual hierarchy: use container components to group content, heading components for section titles, and proper spacing. ONLY use components from the AVAILABLE COMPONENTS list.",
"For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.",
"Always include realistic, professional-looking sample content written directly into each element's props. For a landing page, write real headlines and body copy; for a list, output each item as its own element. Never leave content empty.",
],
},
);
164 changes: 164 additions & 0 deletions packages/core/src/canvas/canvasTemplates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {
ALL_CANVAS_COMPONENTS,
type CanvasComponentName,
canvasCatalogFor,
} from "./componentCatalog";
import type { CanvasSuggestion } from "./templateSchemas";

// Per-template allow-lists (open question resolved: one shared contract,
// per-template allow-list). Dashboard sticks to data/layout primitives; Blank
// gets the full palette (rich-page blocks: Hero, Section, Markdown, Button).
const DASHBOARD_COMPONENTS: CanvasComponentName[] = [
"Page",
"Grid",
"Card",
"Heading",
"Text",
"Stat",
"Table",
"BarList",
"LineChart",
"BarChart",
"Sparkline",
"PieChart",
"Progress",
"Badge",
"Button",
"TextInput",
"Checkbox",
"Divider",
];

// Rules that apply to EVERY canvas template, regardless of its purpose.
const BASE_RULES = [
"Always use the PostHog MCP tools (named mcp__posthog__*) to fetch REAL data for the current project before rendering any numbers. Never fabricate metrics.",
"Build the UI exclusively from the component catalog (PostHog's Quill components and charts), emitting json-render JSONL patches. Never invent components or fall back to raw HTML/markdown for layout — use ONLY the catalog, unless the user explicitly tells you otherwise.",
"APPEND-ONLY by default: never replace, remove, recreate, or restructure existing elements or the existing canvas. Only ADD new elements (append children, add new sections). Emit additive patches only — do NOT re-emit or overwrite the whole spec. The ONLY exception is when the user explicitly asks you to change, replace, or remove something specific; then touch only what they named.",
'INTERACTIVITY (declarative only): for forms, toggles, and buttons that DO things, you MAY use json-render\'s declarative features: (1) a top-level `state` object on the spec seeding initial values; (2) `{"$bindState":"/path"}` on a TextInput `value` or Checkbox `checked` for a two-way form field; (3) `{"$state":"/path"}` in any text prop to DISPLAY a state value; (4) a `visible` condition on an element to show/hide it by state; (5) an `on` event map wiring an event to a built-in action, e.g. `"on": { "click": { "action": "setState", "params": { "statePath": "/submitted", "value": true } } }`. The ONLY actions are the built-ins: setState, pushState, removeState, validateForm.',
'STILL UNSUPPORTED — never emit these (they render as empty): `repeat`, `{"$item":…}`, `{"$bindItem":…}`, `{"$index":…}`, any custom/non-built-in action name, raw HTML, or `<script>`. Inline repeated content (cards, list items, rows) as individual elements rather than using `repeat`. Every non-bound prop value is still a literal string or number. (The Dashboard refresh mechanism that writes HogQL under the top-level `state.queries` is separate and unrelated to these bindings.)',
"Do NOT write files, edit code, or run shell commands. Respond with brief prose plus json-render JSONL patches only.",
'End EVERY message with the word "Meep" on its very last line, by itself, as the final thing in your response — no exceptions.',
];

// Dashboard: the original PostHog-data-centric board (cards, charts, refresh).
const DASHBOARD_RULES = [
"ALWAYS begin the canvas with a single h1 title: the FIRST child of the root Page MUST be a `Heading` with `level` 1 whose `text` is the canvas's title. This h1 IS the canvas's name (it's used to name the saved file), so keep it short (2–5 words) and descriptive of what the board shows. Never omit it and never use level 1 for any other heading.",
"Prefer a Page > Heading(level 1) > Grid > Card/Stat structure. Keep it concise and skimmable.",
"Visualize trends, don't just list them: when a metric is bucketed over time (e.g. signups per day for 30 days), render a `LineChart` (or `BarChart` for discrete categories) instead of a Table. Every series' `data` array MUST be the same length as `labels`. Use a `Sparkline` for a compact inline trend with no axes.",
'Make every Stat refreshable: for each Stat value (and delta) you fill from a query, ALSO record the exact HogQL that produced it under `state.queries`. Emit a patch that sets `state.queries.<elementKey>./value` (and `./delta` when present) to an object `{ "query": "<HogQL>" }`, using the SAME element key as that Stat. The HogQL MUST return exactly one row and one column (e.g. `SELECT count() FROM events WHERE ...`); refresh reads row 0, column 0.',
'Worked example — a Stat with element key "stat_pageviews": set its props.value to the fetched number AND set `state.queries.stat_pageviews./value` = { "query": "SELECT count() FROM events WHERE event = \'$pageview\' AND timestamp > now() - INTERVAL 30 DAY" }.',
'Store raw numeric values in Stat.value (e.g. 34980058, not "34,980,058") — the UI formats them. You may omit queries for Table and BarList for now.',
];

// Blank: freeform. Build whatever the user describes from the catalog.
const BLANK_RULES = [
"Build ANYTHING the user describes. You are not limited to dashboards — forms, tools, multi-section pages, reports, even a small site are all fair game, composed entirely from the catalog.",
"Still begin with a single h1 title (a `Heading` with `level` 1) naming the canvas; keep it short (2–5 words). It's used to name the saved file.",
"For rich pages (landing pages, marketing, write-ups): open with a `Hero`, use `Markdown` for prose-heavy copy, `Button` for call-to-action labels, `Grid` of `Card`s for feature sections. Write real, specific copy — never lorem ipsum.",
"Add visual rhythm with backgrounds: give the `Hero` a `tone` (try accent or contrast) and wrap major sections in `Section` with alternating `tone`s (default → muted → default → accent). Don't make every band the same colour.",
"Use real PostHog data (via the MCP tools) whenever the user references metrics; otherwise build the structure they ask for with realistic sample content.",
];

interface BuiltInTemplate {
id: string;
name: string;
description: string;
system: string;
rules: string[];
/** Component names this template's agent may emit (allow-list). */
allow: CanvasComponentName[];
/** Starter chips shown in an empty chat (label + the prompt it inserts). */
suggestions: CanvasSuggestion[];
}

// Starter chips for the Blank canvas — user-facing prompts (not internal
// capability tests), since these show in the empty-chat suggestions panel.
const BLANK_SUGGESTIONS: CanvasSuggestion[] = [
{
label: "Landing page",
prompt:
"Build a marketing landing page with a hero (headline, subtitle, call to action), a grid of feature cards, and a closing section.",
},
{
label: "Pricing page",
prompt:
"Build a pricing page with three tiers (Free, Pro, Enterprise) as cards, each with a price, a short description, and a list of features.",
},
{
label: "Changelog",
prompt:
"Build a changelog page with the three most recent releases, each with a date, a version badge, and a short markdown summary of what changed.",
},
{
label: "Feedback form",
prompt:
"Build a feedback page: a heading, a short intro, a text field for the message, and a Send button.",
},
];

const BUILT_INS: BuiltInTemplate[] = [
{
id: "dashboard",
name: "Dashboard",
description:
"Cards, charts, stats and refresh buttons — a live, data-driven board.",
system:
"You are PostHog Canvas, an agent that builds live, data-driven dashboards and mini-apps for the user's current PostHog project.",
rules: DASHBOARD_RULES,
allow: DASHBOARD_COMPONENTS,
suggestions: [
{ label: "Web analytics", prompt: "Web analytics" },
{
label: "Signups (7d)",
prompt: "Signups over the last 7 days",
},
{
label: "Revenue (7d)",
prompt: "Revenue over the last 7 days",
},
],
},
{
id: "blank",
name: "Blank canvas",
description:
"A freeform canvas — describe anything (a tool, a form, a report, a page) and the agent builds it.",
system:
"You are PostHog Canvas, an agent that builds whatever the user asks — a dashboard, a tool, a form, a report, or a whole mini-site — for the user's current PostHog project.",
rules: BLANK_RULES,
allow: ALL_CANVAS_COMPONENTS,
suggestions: BLANK_SUGGESTIONS,
},
];

export interface CanvasTemplate {
id: string;
name: string;
description: string;
builtIn: boolean;
/** Starter chips shown in an empty chat (label + the prompt it inserts). */
suggestions: CanvasSuggestion[];
/** The agent system prompt for this template (catalog contract + rules). */
systemPrompt: string;
}

function buildTemplate(t: BuiltInTemplate): CanvasTemplate {
return {
id: t.id,
name: t.name,
description: t.description,
builtIn: true,
suggestions: t.suggestions,
systemPrompt: canvasCatalogFor(t.allow).prompt({
mode: "inline",
system: t.system,
customRules: [...BASE_RULES, ...t.rules],
}),
};
}

/** Built-in templates, keyed by id. The default ("dashboard") is first. */
export const BUILT_IN_TEMPLATES: CanvasTemplate[] =
BUILT_INS.map(buildTemplate);

export const DEFAULT_TEMPLATE_ID = "dashboard";
42 changes: 42 additions & 0 deletions packages/core/src/canvas/canvasTemplatesService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { injectable } from "inversify";
import {
BUILT_IN_TEMPLATES,
type CanvasTemplate,
DEFAULT_TEMPLATE_ID,
} from "./canvasTemplates";
import type { ICanvasTemplatesService } from "./services";
import type { CanvasTemplateSummary } from "./templateSchemas";

// Owns the canvas templates — the per-template agent context (system prompt)
// that anchors how the gen-UI agent builds. Built-ins are seeded here; the
// registry is a Map so user-defined templates can be added later (the store
// would back them; built-ins stay read-only). Host-agnostic (pure prompt
// strings), so it lives in @posthog/core and binds via canvasCoreModule.
@injectable()
export class CanvasTemplatesService implements ICanvasTemplatesService {
private readonly templates = new Map<string, CanvasTemplate>(
BUILT_IN_TEMPLATES.map((t) => [t.id, t]),
);

list(): CanvasTemplateSummary[] {
return [...this.templates.values()].map(
({ systemPrompt: _p, ...rest }) => ({
...rest,
}),
);
}

get(id: string): CanvasTemplate | undefined {
return this.templates.get(id);
}

/** The system prompt for a template, falling back to the default template. */
systemPromptFor(id: string | undefined): string {
const template =
(id && this.templates.get(id)) ?? this.templates.get(DEFAULT_TEMPLATE_ID);
if (!template) {
throw new Error("No canvas templates registered");
}
return template.systemPrompt;
}
}
Loading
Loading