Skip to content

Commit 2675c18

Browse files
committed
feat(canvas): workflows attached to canvases
A workflow canvas = a canvas artifact + a PostHog workflow (HogFlow) + the link between them. The unified canvas generation prompt gains a workflow intent branch: describing an ongoing action ("Send an email after signup") makes the build agent draft a workflow over the PostHog MCP workflows-* tools, author email templates with neutral branding, test every branch, and publish an observability canvas from a baked-in starter board (health or engagement) instead of authoring React from scratch. - Workflow link primitive: the agent-side hooks pair a workflows-create/get result with the canvas publish and emit _posthog/workflow_built; the app writes the link into the dashboard row meta (setWorkflow) and stamps templateId "workflow" (lightning icon in Artifacts, workflow badge in the canvas header, edit-time prompt resolution). - Go-live gating: workflows-enable / run-batch / schedule-create / update-schedule always raise an "Approve & publish" card - even in auto mode, with no always-allow, never persisted - and the cloud agent-server parks (never auto-approves) such requests when no client is reachable. - Generating state now mirrors the agent's live to-do list (StepList) and flips optimistically on submit; a terminal cloud run now clears "Generating" even while the chat session lingers connected. - Hero suggestions gain workflow ideas; repo-less tasks hide the diff chip. Generated-By: PostHog Code Task-Id: 3b50883f-cab5-443a-8b92-3d0c948688da
1 parent bca6aa1 commit 2675c18

34 files changed

Lines changed: 1784 additions & 56 deletions

packages/agent/src/acp-extensions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ export const POSTHOG_NOTIFICATIONS = {
7575
/** PostHog products used during a turn (derived from MCP exec calls) */
7676
RESOURCES_USED: "_posthog/resources_used",
7777

78+
/** A workflow build linked a PostHog workflow to its canvas (derived from the
79+
* workflows-create result + the canvas publish call). The host writes the
80+
* link onto the dashboard row (the agent can't persist it itself). */
81+
WORKFLOW_BUILT: "_posthog/workflow_built",
82+
7883
/** Response to a relayed permission request (plan approval, question) */
7984
PERMISSION_RESPONSE: "_posthog/permission_response",
8085

@@ -93,6 +98,19 @@ export const POSTHOG_NOTIFICATIONS = {
9398
MCP_RESPONSE: "_posthog/mcp_response",
9499
} as const;
95100

101+
/**
102+
* Payload of a `_posthog/workflow_built` notification: the PostHog workflow a
103+
* build attached to its canvas. `dashboardId` is the canvas the workflow
104+
* tracks; the host writes the rest onto that row's meta.
105+
*/
106+
export interface WorkflowBuiltPayload {
107+
dashboardId: string;
108+
workflowId: string;
109+
workflowStatus?: string;
110+
workflowName?: string;
111+
workflowType?: string;
112+
}
113+
96114
export type NativeGoalState = {
97115
objective: string;
98116
status:

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ import {
9696
type TaskState,
9797
taskStateToPlanEntries,
9898
} from "./conversion/task-state";
99-
import type { EnrichedReadCache } from "./hooks";
99+
import type { EnrichedReadCache, WorkflowBuiltSignal } from "./hooks";
100100
import { createLocalToolsMcpServer } from "./mcp/local-tools";
101101
import {
102102
clearMcpToolMetadataCache,
@@ -1975,6 +1975,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
19751975
settingsManager,
19761976
onModeChange: this.createOnModeChange(),
19771977
onPostHogResourceUsed: this.createOnPostHogResourceUsed(),
1978+
onWorkflowBuilt: this.createOnWorkflowBuilt(),
19781979
onProcessSpawned: this.options?.onProcessSpawned,
19791980
onProcessExited: this.options?.onProcessExited,
19801981
effort,
@@ -2277,6 +2278,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
22772278
};
22782279
}
22792280

2281+
/** Emits the workflow link (workflow ↔ canvas) the moment a build forms it,
2282+
* so the host can persist it onto the dashboard row (the agent has no MCP
2283+
* tool to write it itself). Deterministic - derived from the build's tool
2284+
* calls, no model cooperation required. */
2285+
private createOnWorkflowBuilt() {
2286+
return (signal: WorkflowBuiltSignal) => {
2287+
void this.client.extNotification(POSTHOG_NOTIFICATIONS.WORKFLOW_BUILT, {
2288+
sessionId: this.sessionId,
2289+
...signal,
2290+
});
2291+
};
2292+
}
2293+
22802294
/** Adds products to the session-wide set and emits any newly-seen ones.
22812295
* Session-wide dedup: only the first use of a product emits, so the client's
22822296
* persistent list shows each chip once across all turns. */

packages/agent/src/adapters/claude/hooks.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ vi.mock("../../enrichment/file-enricher", () => ({
1010
import { Logger } from "../../utils/logger";
1111
import type { TaskState } from "./conversion/task-state";
1212
import {
13+
createPostToolUseHook,
1314
createPreToolUseHook,
1415
createReadEnrichmentHook,
1516
createReadImageGuardHook,
1617
createSignedCommitGuardHook,
1718
createTaskHook,
1819
type EnrichedReadCache,
20+
type WorkflowBuiltSignal,
1921
} from "./hooks";
2022
import type {
2123
PermissionCheckResult,
@@ -694,3 +696,105 @@ describe("createTaskHook", () => {
694696
expect(state.get("t1")?.subject).toBe("Fix bug");
695697
});
696698
});
699+
700+
describe("createPostToolUseHook workflow link", () => {
701+
function execInput(command: string, tool_response?: unknown): HookInput {
702+
return {
703+
hook_event_name: "PostToolUse",
704+
tool_name: "mcp__posthog__exec",
705+
tool_input: { command },
706+
tool_response,
707+
} as unknown as HookInput;
708+
}
709+
710+
test("pairs a workflows-create result with the canvas publish it precedes", async () => {
711+
const signals: WorkflowBuiltSignal[] = [];
712+
const hook = createPostToolUseHook({
713+
onWorkflowBuilt: (s) => signals.push(s),
714+
});
715+
716+
await hook(
717+
execInput(
718+
"call --json workflows-create {}",
719+
JSON.stringify({
720+
id: "wf-42",
721+
status: "draft",
722+
name: "Welcome sequence",
723+
}),
724+
),
725+
"tu1",
726+
{ signal: new AbortController().signal },
727+
);
728+
await hook(
729+
execInput(
730+
'call --json desktop-file-system-canvas-partial-update {"id":"dash-7","code":"export default () => null;"}',
731+
),
732+
"tu2",
733+
{ signal: new AbortController().signal },
734+
);
735+
736+
expect(signals).toEqual([
737+
{
738+
dashboardId: "dash-7",
739+
workflowId: "wf-42",
740+
workflowStatus: "draft",
741+
workflowName: "Welcome sequence",
742+
},
743+
]);
744+
});
745+
746+
test("links an existing workflow fetched with workflows-get", async () => {
747+
const signals: WorkflowBuiltSignal[] = [];
748+
const hook = createPostToolUseHook({
749+
onWorkflowBuilt: (s) => signals.push(s),
750+
});
751+
752+
// Attach-existing flow: no workflows-create, just a workflows-get on the
753+
// target, then the canvas publish.
754+
await hook(
755+
execInput(
756+
"call --json workflows-get {}",
757+
JSON.stringify({
758+
id: "wf-existing",
759+
status: "active",
760+
name: "Welcome sequence",
761+
}),
762+
),
763+
"tu1",
764+
{ signal: new AbortController().signal },
765+
);
766+
await hook(
767+
execInput(
768+
'call --json desktop-file-system-canvas-partial-update {"id":"dash-9","code":"x"}',
769+
),
770+
"tu2",
771+
{ signal: new AbortController().signal },
772+
);
773+
774+
expect(signals).toEqual([
775+
{
776+
dashboardId: "dash-9",
777+
workflowId: "wf-existing",
778+
workflowStatus: "active",
779+
workflowName: "Welcome sequence",
780+
},
781+
]);
782+
});
783+
784+
test("does not fire on a canvas publish with no workflow created first", async () => {
785+
const signals: WorkflowBuiltSignal[] = [];
786+
const hook = createPostToolUseHook({
787+
onWorkflowBuilt: (s) => signals.push(s),
788+
});
789+
790+
await hook(
791+
execInput(
792+
'call --json desktop-file-system-canvas-partial-update {"id":"dash-7","code":"x"}',
793+
),
794+
"tu1",
795+
{ signal: new AbortController().signal },
796+
);
797+
798+
expect(signals).toEqual([]);
799+
});
800+
});

packages/agent/src/adapters/claude/hooks.ts

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { gitSubcommand } from "./git-command";
1111
import { neutralizeUnprocessableImages } from "./image-sanitization";
1212
import {
1313
extractPostHogSubTool,
14+
isPostHogAlwaysGatedSubTool,
1415
isPostHogDestructiveSubTool,
1516
isPostHogExecTool,
1617
} from "./permissions/posthog-exec-gate";
@@ -55,6 +56,66 @@ function extractTextFromToolResponse(response: unknown): string | null {
5556
return null;
5657
}
5758

59+
// Parse the first JSON object embedded in a string (e.g. the `{...}` arg of a
60+
// PostHog `call --json <tool> {...}` command, or a JSON tool result). Returns
61+
// null when there's no parseable object.
62+
function parseEmbeddedJsonObject(text: string): Record<string, unknown> | null {
63+
const start = text.indexOf("{");
64+
if (start === -1) return null;
65+
try {
66+
const parsed = JSON.parse(text.slice(start));
67+
return parsed && typeof parsed === "object"
68+
? (parsed as Record<string, unknown>)
69+
: null;
70+
} catch {
71+
return null;
72+
}
73+
}
74+
75+
// The `id` (dashboard id) a workflow build's canvas publish targets, read from
76+
// the `desktop-file-system-canvas-partial-update` command's JSON arg.
77+
function parseCanvasPublishDashboardId(toolInput: unknown): string | null {
78+
const command = (toolInput as { command?: unknown })?.command;
79+
if (typeof command !== "string") return null;
80+
const arg = parseEmbeddedJsonObject(command);
81+
return arg && typeof arg.id === "string" ? arg.id : null;
82+
}
83+
84+
// The workflow id + status from a `workflows-create` result. The result JSON may
85+
// be the workflow itself or wrapped, so look under common envelopes too. Best
86+
// effort - returns null if no workflow id is present.
87+
function parseWorkflowFromResponse(response: unknown): {
88+
workflowId: string;
89+
workflowStatus?: string;
90+
workflowName?: string;
91+
} | null {
92+
const text = extractTextFromToolResponse(response);
93+
if (!text) return null;
94+
const root = parseEmbeddedJsonObject(text);
95+
if (!root) return null;
96+
for (const candidate of [
97+
root,
98+
root.workflow,
99+
root.result,
100+
root.data,
101+
] as Record<string, unknown>[]) {
102+
if (candidate && typeof candidate === "object") {
103+
const id = (candidate as { id?: unknown }).id;
104+
if (typeof id === "string" && id) {
105+
const status = (candidate as { status?: unknown }).status;
106+
const name = (candidate as { name?: unknown }).name;
107+
return {
108+
workflowId: id,
109+
workflowStatus: typeof status === "string" ? status : undefined,
110+
workflowName:
111+
typeof name === "string" && name.trim() ? name : undefined,
112+
};
113+
}
114+
}
115+
}
116+
return null;
117+
}
118+
58119
/**
59120
* Per-toolUseId handoff from the PostToolUse hook to `toolUpdateFromToolResult`.
60121
* Can't emit a standalone `tool_call_update` because the SDK emits its own
@@ -198,19 +259,39 @@ export const createTaskHook =
198259

199260
export type OnModeChange = (mode: CodeExecutionMode) => Promise<void>;
200261

262+
// The link a workflow build forms: the workflow (from a workflows-create
263+
// result) tracked by the canvas it publishes (dashboardId from the publish call).
264+
export interface WorkflowBuiltSignal {
265+
dashboardId: string;
266+
workflowId: string;
267+
workflowStatus?: string;
268+
workflowName?: string;
269+
}
270+
201271
interface CreatePostToolUseHookParams {
202272
onModeChange?: OnModeChange;
203273
/** Called after a PostHog MCP `call` exec executes, with the sub-tool name
204274
* and the raw command (the command embeds the SQL for execute-sql). */
205275
onPostHogResourceUsed?: (subTool: string, commandText?: string) => void;
276+
/** Called once a workflow build has both created a workflow and published
277+
* its canvas - the host then writes the link onto the dashboard row. */
278+
onWorkflowBuilt?: (signal: WorkflowBuiltSignal) => void;
206279
}
207280

208-
export const createPostToolUseHook =
209-
({
210-
onModeChange,
211-
onPostHogResourceUsed,
212-
}: CreatePostToolUseHookParams): HookCallback =>
213-
async (
281+
export const createPostToolUseHook = ({
282+
onModeChange,
283+
onPostHogResourceUsed,
284+
onWorkflowBuilt,
285+
}: CreatePostToolUseHookParams): HookCallback => {
286+
// Session-scoped accumulation: the workflow is created before the canvas is
287+
// published, so remember the last workflow this run created and fire the
288+
// link the moment the canvas publish (which carries the dashboardId) lands.
289+
let pendingWorkflow: {
290+
workflowId: string;
291+
workflowStatus?: string;
292+
workflowName?: string;
293+
} | null = null;
294+
return async (
214295
input: HookInput,
215296
toolUseID: string | undefined,
216297
): Promise<{ continue: boolean }> => {
@@ -235,6 +316,28 @@ export const createPostToolUseHook =
235316
}
236317
}
237318

319+
// Observe a workflow build (deterministic, no model cooperation): the
320+
// workflow this run last created OR fetched is the one the canvas tracks;
321+
// the subsequent `desktop-file-system-canvas-partial-update` carries the
322+
// dashboard it publishes to. Pair them into the link the host persists.
323+
// Capturing `workflows-get` (not just `workflows-create`) is what lets a
324+
// build attach a canvas to an EXISTING workflow, not only a new one.
325+
if (onWorkflowBuilt && isPostHogExecTool(toolName)) {
326+
const subTool = extractPostHogSubTool(input.tool_input);
327+
if (subTool === "workflows-create" || subTool === "workflows-get") {
328+
const workflow = parseWorkflowFromResponse(input.tool_response);
329+
if (workflow) pendingWorkflow = workflow;
330+
} else if (
331+
subTool === "desktop-file-system-canvas-partial-update" &&
332+
pendingWorkflow
333+
) {
334+
const dashboardId = parseCanvasPublishDashboardId(input.tool_input);
335+
if (dashboardId) {
336+
onWorkflowBuilt({ dashboardId, ...pendingWorkflow });
337+
}
338+
}
339+
}
340+
238341
if (toolUseID) {
239342
const onPostToolUseHook =
240343
toolUseCallbacks[toolUseID]?.onPostToolUseHook;
@@ -250,6 +353,7 @@ export const createPostToolUseHook =
250353
}
251354
return { continue: true };
252355
};
356+
};
253357

254358
/**
255359
* Rewrites Agent tool calls targeting built-in subagent types to use our custom
@@ -407,13 +511,19 @@ export const createPreToolUseHook =
407511
// so the SDK invokes canUseTool.
408512
if (permissionCheck.decision === "allow" && isPostHogExecTool(toolName)) {
409513
const subTool = extractPostHogSubTool(toolInput);
410-
if (subTool && isPostHogDestructiveSubTool(subTool)) {
514+
// Force "ask" (→ canUseTool) for destructive sub-tools AND for go-live
515+
// workflow tools, so a settings allow-rule can't skip either gate.
516+
if (
517+
subTool &&
518+
(isPostHogDestructiveSubTool(subTool) ||
519+
isPostHogAlwaysGatedSubTool(subTool))
520+
) {
411521
return {
412522
continue: true,
413523
hookSpecificOutput: {
414524
hookEventName: "PreToolUse" as const,
415525
permissionDecision: "ask" as const,
416-
permissionDecisionReason: `Destructive PostHog sub-tool '${subTool}' requires explicit approval`,
526+
permissionDecisionReason: `PostHog sub-tool '${subTool}' requires explicit approval`,
417527
},
418528
};
419529
}

0 commit comments

Comments
 (0)