Skip to content

Commit a825734

Browse files
feat(agent): add local browser use
Add an opt-in isolated Playwright MCP server for every local agent adapter. Generated-By: PostHog Code Task-Id: 18a0ed17-2c63-4288-98b3-65563803ac72
1 parent e02e0c1 commit a825734

14 files changed

Lines changed: 252 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<!-- markdownlint-disable MD013 -->
2+
13
# PostHog Code Development Guide
24

35
`AGENTS.md` is the source of truth for architecture and development rules. `CLAUDE.md` is a symlink to this file. Edit this file only.
@@ -229,6 +231,7 @@ See [docs/conventions.md](./docs/conventions.md).
229231
- Use SDK types from `@anthropic-ai/claude-agent-sdk` and `@agentclientprotocol/sdk`.
230232
- Do not use Claude Code SDK `rawInput`. Use Zod-validated metadata.
231233
- User approvals are tool calls with permissions. Do not model approvals as custom methods plus notifications.
234+
- Keep browser and computer-use capabilities adapter-neutral unless an adapter has a documented technical constraint.
232235

233236
## Key Libraries
234237

docs/BROWSER-USE.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<!-- markdownlint-disable MD013 -->
2+
3+
# Browser use
4+
5+
PostHog Code can give local agent sessions browser automation tools through an isolated Playwright MCP server.
6+
7+
## Enable it
8+
9+
1. Install Google Chrome.
10+
2. Open **Settings → Advanced**.
11+
3. Enable **Browser use**.
12+
4. Start a new local session.
13+
14+
The setting applies when a session starts. Existing sessions are unchanged.
15+
16+
## Behavior
17+
18+
- Browser use is opt-in and disabled by default.
19+
- It is available only to local sessions.
20+
- Each session launches an isolated Chrome profile, so it does not inherit cookies or logins from the user's normal browser profile.
21+
- Tool calls and screenshots use the existing MCP tool-call pipeline.
22+
- Cloud sessions do not receive the local browser server.
23+
24+
## Scope
25+
26+
This feature automates websites in a dedicated Chrome window. It does not control arbitrary desktop applications or the user's existing browser windows.
27+
28+
## Implementation
29+
30+
The workspace session layer injects a pinned `@playwright/mcp` stdio server for every supported local agent adapter instead of maintaining a bespoke browser-control protocol. This keeps browser lifecycle, accessibility snapshots, input actions, and image responses on Playwright's supported MCP implementation while reusing PostHog Code's existing MCP tool-call and approval surfaces.

packages/core/src/sessions/sessionService.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ export interface SessionServiceDeps {
337337
rtkEnabledCloud?: boolean;
338338
spokenNotifications?: boolean;
339339
spokenNarrationEnabled?: boolean;
340+
browserUse?: boolean;
340341
};
341342
usageLimit: { show: (...args: any[]) => any };
342343
readonly addDirectoryDialog: { open: boolean };
@@ -1088,14 +1089,19 @@ export class SessionService {
10881089
this.d.log.warn("Failed to verify workspace", { taskId, err });
10891090
});
10901091

1091-
const { customInstructions, rtkEnabledLocal, spokenNarrationEnabled } =
1092-
this.d.settings;
1092+
const {
1093+
customInstructions,
1094+
rtkEnabledLocal,
1095+
spokenNarrationEnabled,
1096+
browserUse,
1097+
} = this.d.settings;
10931098
const result = await this.d.trpc.agent.reconnect.mutate({
10941099
taskId,
10951100
taskRunId,
10961101
repoPath,
10971102
rtkEnabled: rtkEnabledLocal,
10981103
spokenNarration: spokenNarrationEnabled === true,
1104+
browserUse: browserUse === true,
10991105
apiHost: auth.apiHost,
11001106
projectId: auth.projectId,
11011107
logUrl,
@@ -1419,6 +1425,7 @@ export class SessionService {
14191425
customInstructions: startCustomInstructions,
14201426
rtkEnabledLocal,
14211427
spokenNarrationEnabled,
1428+
browserUse,
14221429
} = this.d.settings;
14231430
const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL;
14241431
const result = await this.d.trpc.agent.start.mutate({
@@ -1432,6 +1439,7 @@ export class SessionService {
14321439
customInstructions: startCustomInstructions || undefined,
14331440
rtkEnabled: rtkEnabledLocal,
14341441
spokenNarration: spokenNarrationEnabled === true,
1442+
browserUse: browserUse === true,
14351443
effort: effortLevelSchema.safeParse(reasoningLevel).success
14361444
? (reasoningLevel as EffortLevel)
14371445
: undefined,

packages/ui/src/features/sessions/sessionServiceHost.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ function buildSessionServiceDeps(): SessionServiceDeps {
137137
),
138138
import.meta.env.DEV,
139139
),
140+
browserUse: state.browserUse,
140141
};
141142
},
142143
usageLimit: {

packages/ui/src/features/settings/sections/AdvancedSettings.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export function AdvancedSettings() {
3333
const setRtkEnabledLocal = useSettingsStore((s) => s.setRtkEnabledLocal);
3434
const rtkEnabledCloud = useSettingsStore((s) => s.rtkEnabledCloud);
3535
const setRtkEnabledCloud = useSettingsStore((s) => s.setRtkEnabledCloud);
36+
const browserUse = useSettingsStore((s) => s.browserUse);
37+
const setBrowserUse = useSettingsStore((s) => s.setBrowserUse);
3638
const hostTRPC = useHostTRPC();
3739
const { data: rtkStatus } = useQuery(hostTRPC.agent.rtkStatus.queryOptions());
3840
const devModeClient = useServiceOptional<DevModeClient>(DEV_MODE_CLIENT);
@@ -88,6 +90,12 @@ export function AdvancedSettings() {
8890
)}
8991
</Flex>
9092
</SettingRow>
93+
<SettingRow
94+
label="Browser use"
95+
description="Let local agent sessions launch an isolated Google Chrome window and interact with websites. Experimental; requires Chrome to be installed"
96+
>
97+
<Switch checked={browserUse} onCheckedChange={setBrowserUse} size="1" />
98+
</SettingRow>
9199
<SettingRow
92100
label="Reset onboarding and tours"
93101
description="Re-run the onboarding tutorial and product tours on next app restart"

packages/ui/src/features/settings/settingsStore.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ describe("feature settingsStore defaults", () => {
4747
expect(useSettingsStore.getState().lastUsedLocalWorkspaceMode).toBe(
4848
"local",
4949
);
50+
expect(useSettingsStore.getState().browserUse).toBe(false);
5051
});
5152
});
5253

@@ -220,6 +221,7 @@ describe("feature settingsStore cloud selections", () => {
220221
["slotMachineMode", false, true],
221222
["dismissibleUpdateBanners", false, true],
222223
["showSidebarWorktrees", false, true],
224+
["browserUse", false, true],
223225
] as const)("rehydrates %s", async (field, initial, persisted) => {
224226
getItem.mockResolvedValue(
225227
JSON.stringify({ state: { [field]: persisted }, version: 0 }),

packages/ui/src/features/settings/settingsStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,14 @@ interface SettingsStore {
211211
// sessions, cloud covers cloud runs.
212212
rtkEnabledLocal: boolean;
213213
rtkEnabledCloud: boolean;
214+
browserUse: boolean;
214215
setAllowBypassPermissions: (enabled: boolean) => void;
215216
setPreventSleepWhileRunning: (enabled: boolean) => void;
216217
setDebugLogsCloudRuns: (enabled: boolean) => void;
217218
setAutoPublishCloudRuns: (enabled: boolean) => void;
218219
setRtkEnabledLocal: (enabled: boolean) => void;
219220
setRtkEnabledCloud: (enabled: boolean) => void;
221+
setBrowserUse: (enabled: boolean) => void;
220222

221223
// Terminal
222224
terminalFont: TerminalFont;
@@ -428,6 +430,7 @@ export const useSettingsStore = create<SettingsStore>()(
428430
autoPublishCloudRuns: true,
429431
rtkEnabledLocal: true,
430432
rtkEnabledCloud: true,
433+
browserUse: false,
431434
setAllowBypassPermissions: (enabled) =>
432435
set({ allowBypassPermissions: enabled }),
433436
setPreventSleepWhileRunning: (enabled) =>
@@ -437,6 +440,7 @@ export const useSettingsStore = create<SettingsStore>()(
437440
set({ autoPublishCloudRuns: enabled }),
438441
setRtkEnabledLocal: (enabled) => set({ rtkEnabledLocal: enabled }),
439442
setRtkEnabledCloud: (enabled) => set({ rtkEnabledCloud: enabled }),
443+
setBrowserUse: (enabled) => set({ browserUse: enabled }),
440444

441445
// Terminal
442446
terminalFont: "berkeley-mono",
@@ -566,6 +570,7 @@ export const useSettingsStore = create<SettingsStore>()(
566570
autoPublishCloudRuns: state.autoPublishCloudRuns,
567571
rtkEnabledLocal: state.rtkEnabledLocal,
568572
rtkEnabledCloud: state.rtkEnabledCloud,
573+
browserUse: state.browserUse,
569574

570575
// Terminal
571576
terminalFont: state.terminalFont,

packages/workspace-server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@hono/trpc-server": "catalog:",
2626
"@modelcontextprotocol/sdk": "^1.29.0",
2727
"@parcel/watcher": "catalog:",
28+
"@playwright/mcp": "0.0.78",
2829
"@posthog/agent": "workspace:*",
2930
"@posthog/di": "workspace:*",
3031
"@posthog/enricher": "workspace:*",

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,19 @@ vi.mock("@posthog/agent/adapters/claude/session/jsonl-hydration", () => ({
116116
hydrateSessionJsonl: vi.fn().mockResolvedValue(undefined),
117117
}));
118118

119+
vi.mock("./browser-use-mcp", () => ({
120+
buildBrowserUseServer: vi.fn((enabled: boolean | undefined) =>
121+
enabled
122+
? {
123+
name: "playwright",
124+
command: process.execPath,
125+
args: ["/mock/playwright-mcp/cli.js", "--isolated"],
126+
env: [{ name: "ELECTRON_RUN_AS_NODE", value: "1" }],
127+
}
128+
: null,
129+
),
130+
}));
131+
119132
vi.mock("node:fs", async (importOriginal) => {
120133
const original = await importOriginal<typeof import("node:fs")>();
121134
return {
@@ -345,6 +358,48 @@ describe("AgentService", () => {
345358
expect(codexMcp).toEqual(claudeMcp);
346359
});
347360

361+
it("passes the same browser MCP server to Claude and Codex", async () => {
362+
await service.startSession({
363+
...baseSessionParams,
364+
taskRunId: "run-claude",
365+
adapter: "claude",
366+
browserUse: true,
367+
});
368+
await service.startSession({
369+
...baseSessionParams,
370+
taskRunId: "run-codex",
371+
adapter: "codex",
372+
browserUse: true,
373+
});
374+
375+
const claudeBrowserServer =
376+
mockNewSession.mock.calls[0][0].mcpServers.at(-1);
377+
const codexBrowserServer =
378+
mockNewSession.mock.calls[1][0].mcpServers.at(-1);
379+
expect(claudeBrowserServer).toMatchObject({
380+
name: "playwright",
381+
args: expect.arrayContaining(["--isolated"]),
382+
});
383+
expect(codexBrowserServer).toEqual(claudeBrowserServer);
384+
});
385+
386+
it.each([undefined, false])(
387+
"does not pass browser MCP when browserUse is %s",
388+
async (browserUse) => {
389+
await service.startSession({
390+
...baseSessionParams,
391+
adapter: "claude",
392+
browserUse,
393+
});
394+
395+
expect(mockNewSession.mock.calls[0][0].mcpServers).not.toEqual(
396+
expect.arrayContaining([
397+
expect.objectContaining({ name: "playwright" }),
398+
]),
399+
);
400+
},
401+
);
402+
348403
it("drops unreachable MCP servers for codex but keeps them for claude", async () => {
349404
vi.stubGlobal(
350405
"fetch",
@@ -414,6 +469,19 @@ describe("AgentService", () => {
414469
"spokenNarration",
415470
);
416471
});
472+
473+
it("keeps browser configuration out of adapter-specific session meta", async () => {
474+
await service.startSession({
475+
...baseSessionParams,
476+
adapter: "claude",
477+
browserUse: true,
478+
});
479+
480+
expect(mockNewSession).toHaveBeenCalledTimes(1);
481+
expect(mockNewSession.mock.calls[0][0]._meta).not.toHaveProperty(
482+
"browserUse",
483+
);
484+
});
417485
});
418486

419487
describe("permission requests", () => {

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import type { ProcessTrackingService } from "../process-tracking/process-trackin
8888
import { loadSessionEnvOverrides } from "../session-env/loader";
8989
import { isScratchPath } from "../workspace/scratch";
9090
import type { AgentAuthAdapter, McpToolInstallations } from "./auth-adapter";
91+
import { buildBrowserUseServer } from "./browser-use-mcp";
9192
import { cleanupCodexHome, prepareCodexHome } from "./codex-home";
9293
import { discoverExternalPlugins } from "./discover-plugins";
9394
import {
@@ -290,6 +291,8 @@ interface SessionConfig {
290291
rtkEnabled?: boolean;
291292
/** The user's spoken-narration setting at session start. */
292293
spokenNarration?: boolean;
294+
/** Whether local sessions may launch the isolated browser-use tools. */
295+
browserUse?: boolean;
293296
}
294297

295298
/** Pull the adapter's `agentCapabilities._meta.posthog.steering` from initialize. */
@@ -935,10 +938,22 @@ If a repository IS genuinely required, attach one in this priority order:
935938
// ("ACP connection closed") and makes the host silently fall back to a
936939
// Claude/Opus session. Claude connects lazily and is unaffected, so only
937940
// the Codex server list is pruned to the reachable ones.
938-
const sessionMcpServers =
941+
const reachableMcpServers =
939942
adapter === "codex"
940943
? await this.filterReachableMcpServers(mcpServers, taskRunId)
941944
: mcpServers;
945+
let browserUseServer: ReturnType<typeof buildBrowserUseServer> = null;
946+
try {
947+
browserUseServer = buildBrowserUseServer(config.browserUse, "local");
948+
} catch (err) {
949+
this.log.warn("Browser-use server unavailable; continuing without it", {
950+
error: err instanceof Error ? err.message : String(err),
951+
});
952+
}
953+
const sessionMcpServers = [
954+
...reachableMcpServers,
955+
...(browserUseServer ? [browserUseServer] : []),
956+
];
942957

943958
let externalPlugins: Awaited<ReturnType<typeof discoverExternalPlugins>> =
944959
[];
@@ -2028,6 +2043,7 @@ For git operations while detached:
20282043
rtkEnabled: "rtkEnabled" in params ? params.rtkEnabled : undefined,
20292044
spokenNarration:
20302045
"spokenNarration" in params ? params.spokenNarration : undefined,
2046+
browserUse: "browserUse" in params ? params.browserUse : undefined,
20312047
};
20322048
}
20332049

0 commit comments

Comments
 (0)