Skip to content

Commit fefc6d6

Browse files
committed
feat(canvas): create-if-missing checkout, authoring contract, discovery + allowlist
Build on the canvas_checkout/canvas_publish tools so canvas work is reachable and correct from any task, not just the channel generate bar: - canvas_checkout takes an optional id (edit) OR a name (create-if-missing), so the agent starts a canvas in one call instead of chaining desktop-fs create. - checkout returns the freeform authoring contract (from @posthog/shared), so an agent editing a canvas from a normal task authors valid source. - a channelMode-gated "## Canvases" block in the base system prompt names the tools + the create->checkout->publish sequence (fixes ToolSearch flakiness). - add a declarative autoApprove flag to local tools; canvas_checkout (read-only) and speak are auto-approved, canvas_publish (write) still prompts. - createDesktopCanvas API helper + allowlist regression test. Generated-By: PostHog Code Task-Id: 2ed89933-091b-446c-af91-5cc72d939b3b
1 parent 3712a2c commit fefc6d6

9 files changed

Lines changed: 165 additions & 27 deletions

File tree

packages/agent/src/adapters/claude/permissions/permission-handlers.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ import type {
99
} from "@anthropic-ai/claude-agent-sdk";
1010
import { text } from "../../../utils/acp-content";
1111
import type { Logger } from "../../../utils/logger";
12-
import { qualifiedLocalToolName } from "../../local-tools";
13-
import { SPEAK_TOOL_NAME } from "../../local-tools/tools/speak";
12+
import { AUTO_APPROVED_LOCAL_TOOL_IDS } from "../../local-tools";
1413
import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp";
1514
import {
1615
getMcpToolApprovalState,
@@ -40,8 +39,6 @@ import {
4039
isPostHogExecTool,
4140
} from "./posthog-exec-gate";
4241

43-
const SPEAK_TOOL_ID = qualifiedLocalToolName(SPEAK_TOOL_NAME);
44-
4542
export type ToolPermissionResult =
4643
| {
4744
behavior: "allow";
@@ -761,10 +758,10 @@ export async function canUseTool(
761758
return { behavior: "deny", message, interrupt: false };
762759
}
763760

764-
// Narration is a fire-and-forget no-op on the agent side; a permission
765-
// prompt for it interrupts the user to approve a line they may never hear.
766-
// An explicit do_not_use block above still wins.
767-
if (toolName === SPEAK_TOOL_ID) {
761+
// Auto-approve tools flagged `autoApprove` — read-only or fire-and-forget
762+
// (canvas checkout, speak narration) where a permission prompt is pure
763+
// friction. An explicit do_not_use block above still wins.
764+
if (AUTO_APPROVED_LOCAL_TOOL_IDS.has(toolName)) {
768765
return {
769766
behavior: "allow",
770767
updatedInput: toolInput as Record<string, unknown>,

packages/agent/src/adapters/local-tools/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry";
1+
import {
2+
type LocalTool,
3+
type LocalToolCtx,
4+
type LocalToolGateMeta,
5+
qualifiedLocalToolName,
6+
} from "./registry";
27
import { canvasCheckoutTool, canvasPublishTool } from "./tools/canvas";
38
import { cloneRepoTool } from "./tools/clone-repo";
49
import { listReposTool } from "./tools/list-repos";
@@ -28,6 +33,17 @@ export const LOCAL_TOOLS: LocalTool[] = [
2833
canvasPublishTool,
2934
];
3035

36+
/**
37+
* Qualified ids of local tools flagged `autoApprove` — the Claude permission
38+
* handler allows these without a prompt. Codex does not prompt for MCP tool
39+
* calls, so it does not consult this.
40+
*/
41+
export const AUTO_APPROVED_LOCAL_TOOL_IDS: ReadonlySet<string> = new Set(
42+
LOCAL_TOOLS.filter((t) => t.autoApprove).map((t) =>
43+
qualifiedLocalToolName(t.name),
44+
),
45+
);
46+
3147
/** Tools whose gate passes for the given context — the set to actually expose. */
3248
export function enabledLocalTools(
3349
ctx: LocalToolCtx,

packages/agent/src/adapters/local-tools/registry.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ export interface LocalToolDef<S extends z.ZodRawShape> {
5353
* by default in the Claude adapter (ENABLE_TOOL_SEARCH). Ignored by Codex.
5454
*/
5555
alwaysLoad?: boolean;
56+
/**
57+
* Auto-approve this tool's calls without a permission prompt — for read-only
58+
* or fire-and-forget tools where prompting is pure friction (an explicit
59+
* do_not_use block still wins). Honored by the Claude permission handler;
60+
* Codex does not prompt for MCP tool calls, so it is a no-op there.
61+
*/
62+
autoApprove?: boolean;
5663
isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean;
5764
handler(
5865
ctx: LocalToolCtx,
@@ -66,6 +73,8 @@ export interface LocalTool {
6673
description: string;
6774
schema: z.ZodRawShape;
6875
alwaysLoad?: boolean;
76+
/** See {@link LocalToolDef.autoApprove}. */
77+
autoApprove?: boolean;
6978
isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean;
7079
handler(
7180
ctx: LocalToolCtx,

packages/agent/src/adapters/local-tools/tools/canvas.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vitest";
2+
import { AUTO_APPROVED_LOCAL_TOOL_IDS } from "../index";
23
import { canvasScratchDir, canvasScratchFile } from "./canvas";
34

45
// Version composition and the stale-base rejection live server-side in the
@@ -12,3 +13,18 @@ describe("canvas scratch paths", () => {
1213
);
1314
});
1415
});
16+
17+
describe("canvas tool permissions", () => {
18+
it("auto-approves canvas_checkout (read-only) but not canvas_publish (write)", () => {
19+
expect(
20+
AUTO_APPROVED_LOCAL_TOOL_IDS.has(
21+
"mcp__posthog-code-tools__canvas_checkout",
22+
),
23+
).toBe(true);
24+
expect(
25+
AUTO_APPROVED_LOCAL_TOOL_IDS.has(
26+
"mcp__posthog-code-tools__canvas_publish",
27+
),
28+
).toBe(false);
29+
});
30+
});

packages/agent/src/adapters/local-tools/tools/canvas.ts

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
import * as path from "node:path";
3+
import { freeformSystemPromptFor } from "@posthog/shared/canvas-freeform-prompt";
4+
import { FREEFORM_STARTER_CODE } from "@posthog/shared/canvas-freeform-starter";
35
import { z } from "zod";
46
import {
57
DesktopCanvasVersionConflictError,
@@ -48,6 +50,7 @@ function baseVersionMarkerFile(canvasId: string): string {
4850
interface CanvasMeta {
4951
code?: string;
5052
currentVersionId?: string;
53+
templateId?: string;
5154
[key: string]: unknown;
5255
}
5356

@@ -92,6 +95,43 @@ async function fetchCanvasEntry(canvasId: string): Promise<CanvasFsEntry> {
9295
return entry;
9396
}
9497

98+
// Create-if-missing for `canvas_checkout`: an agent working from a normal task
99+
// (not the channel generate bar) can start a canvas in one call instead of
100+
// chaining the raw desktop-fs create tool first.
101+
async function createCanvasEntry(
102+
name: string | undefined,
103+
parentPath: string | undefined,
104+
): Promise<CanvasFsEntry> {
105+
if (!name?.trim()) {
106+
throw new Error(
107+
"pass `id` to edit an existing canvas, or `name` to create a new one.",
108+
);
109+
}
110+
const client = createClient();
111+
if (!client) {
112+
throw new Error("No PostHog credentials available in this session.");
113+
}
114+
return client.createDesktopCanvas<CanvasFsEntry>({
115+
name: name.trim(),
116+
parentPath,
117+
});
118+
}
119+
120+
// The freeform authoring rules (allowed imports, the `ph` data shim, style
121+
// rules) the channel generate bar injects up front. Returned on checkout so an
122+
// agent editing a canvas from any task authors valid source; an empty canvas
123+
// also gets the known-good starter scaffold to build on.
124+
function authoringContract(
125+
templateId: string | undefined,
126+
isEmpty: boolean,
127+
): string {
128+
const contract = freeformSystemPromptFor(templateId);
129+
const starter = isEmpty
130+
? `\n\nStarter scaffold — write this working baseline to the scratch file first, then build by editing it:\n\n\`\`\`tsx\n${FREEFORM_STARTER_CODE}\n\`\`\``
131+
: "";
132+
return `Authoring contract for this canvas (imports, the \`ph\` data shim, and style rules):\n\n${contract}${starter}`;
133+
}
134+
95135
function readMarker(canvasId: string): BaseVersionMarker | undefined {
96136
try {
97137
return JSON.parse(
@@ -119,32 +159,56 @@ const CONFLICT_MESSAGE = (canvasId: string) =>
119159
export const canvasCheckoutTool = defineLocalTool({
120160
name: "canvas_checkout",
121161
description:
122-
"Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing: fetches the live " +
123-
"source, writes it to a local scratch file, and records the version your edits are based on. " +
124-
"Returns the file path. Edit that file with your normal file-editing tools, then call " +
125-
"canvas_publish to save. Always start canvas work with this tool.",
162+
"Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing. Pass `id` to edit " +
163+
"an existing canvas, or omit `id` and pass `name` to create a fresh one. Fetches (or creates) the " +
164+
"canvas, writes its source to a local scratch file, records the base version for the publish-time " +
165+
"concurrency guard, and returns the scratch path plus the authoring contract to follow. Edit that " +
166+
"file with your normal file-editing tools, then call canvas_publish. Always start canvas work with " +
167+
"this tool.",
126168
schema: {
127-
id: z.string().describe("The canvas (desktop-fs dashboard row) id."),
169+
id: z
170+
.string()
171+
.optional()
172+
.describe(
173+
"Existing canvas (desktop-fs dashboard row) id to edit. Omit to create a new canvas via `name`.",
174+
),
175+
name: z
176+
.string()
177+
.optional()
178+
.describe(
179+
"Name for a NEW canvas when `id` is omitted — creates it, then checks it out.",
180+
),
181+
parentPath: z
182+
.string()
183+
.optional()
184+
.describe(
185+
"Optional desktop-fs folder path to create the new canvas under (e.g. a channel folder). Defaults to the top level.",
186+
),
128187
},
129188
alwaysLoad: true,
189+
autoApprove: true,
130190
isEnabled: () => resolveSandboxPosthogApi() !== undefined,
131191
handler: async (_ctx, args): Promise<LocalToolResult> => {
132192
try {
133-
const entry = await fetchCanvasEntry(args.id);
134-
const file = canvasScratchFile(args.id);
193+
const entry = args.id
194+
? await fetchCanvasEntry(args.id)
195+
: await createCanvasEntry(args.name, args.parentPath);
196+
const canvasId = entry.id;
197+
const file = canvasScratchFile(canvasId);
135198
const code = entry.meta?.code ?? "";
136-
mkdirSync(canvasScratchDir(args.id), { recursive: true });
199+
mkdirSync(canvasScratchDir(canvasId), { recursive: true });
137200
writeFileSync(file, code);
138-
writeMarker(args.id, {
201+
writeMarker(canvasId, {
139202
versionId: entry.meta?.currentVersionId,
140203
fetchedAt: Date.now(),
141204
});
142205
const lines = code ? code.split("\n").length : 0;
143-
const text = code
144-
? `Checked out canvas "${entry.path}" to ${file} (${lines} lines, base version ${
206+
const header = code
207+
? `Checked out canvas "${entry.path}" (id ${canvasId}) to ${file} (${lines} lines, base version ${
145208
entry.meta?.currentVersionId ?? "none"
146-
}).\nApply your changes by editing that file, then call canvas_publish with id "${args.id}".`
147-
: `Canvas "${entry.path}" is empty. Author the complete single-file React app at ${file}, then call canvas_publish with id "${args.id}".`;
209+
}). Edit that file, then call canvas_publish with id "${canvasId}".`
210+
: `Canvas "${entry.path}" (id ${canvasId}) is empty — author the complete single-file React app at ${file}, then call canvas_publish with id "${canvasId}".`;
211+
const text = `${header}\n\n${authoringContract(entry.meta?.templateId, !code)}`;
148212
return { content: [{ type: "text", text }] };
149213
} catch (err) {
150214
return errorResult(

packages/agent/src/adapters/local-tools/tools/speak.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const speakTool = defineLocalTool({
5959
description: SPEAK_TOOL_DESCRIPTION,
6060
schema: speakSchema,
6161
alwaysLoad: true,
62+
autoApprove: true,
6263
isEnabled: (_ctx, meta) => meta?.spokenNarration === true,
6364
handler: async (): Promise<LocalToolResult> => {
6465
return { content: [{ type: "text", text: "ok" }] };

packages/agent/src/posthog-api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,31 @@ export class PostHogAPIClient {
364364
return response.json() as Promise<T>;
365365
}
366366

367+
/**
368+
* Create a new freeform canvas ("dashboard" row) on the desktop surface and
369+
* return the created entry (with its id). `parentPath` nests it under a
370+
* folder (e.g. a channel); omit it for a top-level canvas.
371+
*/
372+
async createDesktopCanvas<T>(input: {
373+
name: string;
374+
parentPath?: string;
375+
}): Promise<T> {
376+
const teamId = this.getTeamId();
377+
const parent = input.parentPath?.replace(/\/+$/, "");
378+
const path = parent ? `${parent}/${input.name}` : input.name;
379+
const response = await this.performRequestWithRetry(
380+
`/api/projects/${teamId}/desktop_file_system/`,
381+
{
382+
method: "POST",
383+
body: JSON.stringify({ path, type: "dashboard", meta: {} }),
384+
},
385+
);
386+
if (!response.ok) {
387+
throw new Error(`Failed to create canvas (${response.status})`);
388+
}
389+
return response.json() as Promise<T>;
390+
}
391+
367392
/**
368393
* Publish a freeform canvas's source via the desktop-fs canvas action. The
369394
* server owns version composition (appends the full-file snapshot, moves

packages/ui/src/features/canvas/AGENTS.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,13 @@ The root `AGENTS.md` architecture rules still apply.
8080
version composition server-side and accepts an optional
8181
`expected_current_version_id`: a publish based on a stale version (a
8282
concurrent edit or undo) is rejected 409 inside the row lock instead of
83-
clobbering the newer head. The **agent publish path uses that guard**:
84-
generation works the source as a local scratch file via the
85-
`canvas_checkout` / `canvas_publish` local tools (`@posthog/agent`,
86-
`adapters/local-tools/tools/canvas.ts`) — checkout records the fetched
87-
`currentVersionId`, publish passes it as the expected version (backends
83+
clobbering the newer head. The **agent edit path uses that guard**: the agent
84+
works the source as a local scratch file via the `canvas_checkout` /
85+
`canvas_publish` local tools (`@posthog/agent`,
86+
`adapters/local-tools/tools/canvas.ts`) — `canvas_checkout` fetches an
87+
existing canvas (or creates one from a `name`), writes the scratch file,
88+
records the fetched `currentVersionId`, and returns the authoring contract;
89+
`canvas_publish` passes that version as the expected version (backends
8890
predating the field ignore it and publish unguarded). **User-side saves**
8991
(`saveFreeform`) are still last-write-wins; adopt the same guard if
9092
multi-client editing becomes real.

packages/workspace-server/src/services/agent/agent.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,14 @@ If a repository IS genuinely required, attach one in this priority order:
673673
1. **Reuse a folder the user already has locally.** ${localFolders.length ? "Pick the one that best matches the request and the channel CONTEXT.md, then `cd` into its absolute path and do all git and file work there. It is already on disk — do NOT clone it again." : "If the user names a folder or path, `cd` into that absolute path and work there."}
674674
2. **If you can't confidently pick one** (none clearly match, or it's ambiguous), use the AskUserQuestion tool to ask the user which local folder to use, or for the path where the folder lives on this machine. Do not guess.
675675
3. **Only as a last resort** — when the user has no local copy, or explicitly wants a fresh checkout — clone from remote. Call \`list_repos\` to see what's available (prefer repos named in CONTEXT.md), then **confirm with the user via AskUserQuestion before cloning**, and use \`clone_repo\` (pass \`owner/repo\`); it clones into a subdirectory of your working directory and returns the path to \`cd\` into.${localFoldersBlock}`;
676+
677+
prompt += `
678+
679+
## Canvases
680+
A canvas is an agent-authored single-file React app that lives in PostHog (a desktop-fs "dashboard" row), not a file on disk. Build and edit canvases only through the \`canvas_checkout\` / \`canvas_publish\` tools (posthog-code-tools) — never publish canvas source through the raw PostHog MCP:
681+
- Edit an existing canvas: \`canvas_checkout\` with its id, edit the scratch file it writes, then \`canvas_publish\`.
682+
- Create a new canvas: \`canvas_checkout\` with a \`name\` (omit the id), author the scratch file, then \`canvas_publish\`.
683+
\`canvas_checkout\` returns the authoring contract (allowed imports, the \`ph\` data shim, style rules) — follow it. Editing through these tools is what applies the scratch-file workflow and the publish-time concurrency guard.`;
676684
}
677685

678686
if (customInstructions) {

0 commit comments

Comments
 (0)