Makes Claude drive browsers faster: a browsing playbook, smaller default screenshots, and nudges to batch actions.

A Claude Code mod that makes Claude cheaper and faster at driving a browser (the built-in browser pane, Claude in Chrome, or Playwright MCP).
form_input for fields, and navigates straight to known URLs.scale is taken at 0.6 scale (about 64% fewer image tokens). This applies inside browser_batch too. Click coordinates stay in the full-size frame, and an explicit scale is left alone.browser_batch that clicks by pixel coordinate right after a navigate, with no screenshot in between, is refused before any step runs, because the browser would reject that click. Claude is told to look up the element with find and click by ref instead. A single coordinate click that fails for the same reason gets the same hint.find that matched 10 or more elements (use the exact link text).browser_batch call.browser N calls · M batched · ~Xk tokens saved. The savings count is measured from each screenshot the mod scaled.On a multi-page Wikipedia task (search, read a fact, follow a link, read again), the same task used 24% fewer tokens with browser-boost, and about 40% fewer once avoidable lookup mistakes were removed. The browser needed half as many calls. On a two-screenshot task, the saving was about 8%: the gain grows with the number of screenshots.
/plugin install browser-boost --marketplace Rukono/browser-boost
Answer y to add the marketplace, then pick a scope (user scope enables it everywhere).
Edit the constants at the top of hooks/lib.ts:
SCREENSHOT_SCALE (default 0.6): the default screenshot scale.NUDGE_AFTER (default 3): how many single calls in a row trigger the nudge.BROAD_FIND (default 10): how many find matches trigger the be-specific hint.claude plugin test .
MIT
hooks/register.ts 70 lines1import type { Register } from 'claude-code'
2import {
3 type Action,
4 HINTS,
5 NUDGE_AFTER,
6 PLAYBOOK,
7 formatStatus,
8 resultHints,
9 staleCoordinateAction,
10 tokensSaved,
11 withScale,
12} from './lib.ts'
13
14// Browser MCP servers whose `computer` tool takes a `scale` and that have a `browser_batch`.
15const BATCHING = /^mcp__(Claude_Browser|claude-in-chrome)__/
16const BROWSER = /^mcp__(Claude_Browser|claude-in-chrome|playwright)__/
17
18export const register: Register = on => {
19 let streak = 0
20 let calls = 0
21 let batched = 0
22 let saved = 0
23
24 // Rides the first user message's context blocks, so it stays in the prompt cache.
25 on('prompt.context', async ($, e, next) => {
26 const ctx = await next(e)
27 return { ...ctx, blocks: [...ctx.blocks, { name: 'browserPlaybook', text: PLAYBOOK }] }
28 })
29
30 on('tool.call', { tool: /^mcp__(Claude_Browser|claude-in-chrome|playwright)__/ }, async ($, e, next) => {
31 const name = e.tool.replace(BROWSER, '')
32 const isBatching = BATCHING.test(e.tool)
33 let input = e
34
35 if (isBatching && name === 'computer') {
36 input = withScale(e as Record<string, unknown>) as typeof e
37 } else if (isBatching && name === 'browser_batch' && Array.isArray(e.actions)) {
38 // The browser refuses a coordinate click on a page it hasn't screenshotted; catch it before any step runs.
39 const stale = staleCoordinateAction(e.actions as Action[])
40 if (stale !== -1) {
41 return {
42 deny: `browser-boost: actions[${stale}] clicks by coordinate after a navigate with no screenshot in between, so it would fail. Batch navigate + find first, then click by ref in a second batch.`,
43 }
44 }
45 const actions = (e.actions as Action[]).map(a =>
46 a.name === 'computer' && a.input ? { ...a, input: withScale(a.input) } : a,
47 )
48 input = { ...e, actions } as typeof e
49 }
50
51 const ran = await next(input)
52 if (ran.deny !== undefined) return ran
53
54 const text = ran.text ?? ''
55 calls++
56 saved += tokensSaved(text)
57 if (name === 'browser_batch') {
58 batched++
59 streak = 0
60 } else if (isBatching) {
61 streak++
62 }
63 $.ui.status(formatStatus(calls, batched, saved))
64
65 const hints = resultHints(text)
66 if (streak === NUDGE_AFTER && ran.isError === undefined) hints.push(HINTS.batch)
67 return hints.length > 0 ? { ...ran, context: [...(ran.context ?? []), ...hints] } : ran
68 }).catch(($, e, next) => next(e))
69}
70hooks/lib.ts 84 lines1// Pure helpers for browser-boost: no engine calls, so tests can exercise them directly.
2
3export type Action = { name?: string; input?: Record<string, unknown> }
4
5// Default screenshot scale when the model leaves it unset; click coordinates stay full-frame.
6export const SCREENSHOT_SCALE = 0.6
7// Standalone browser calls in a row before suggesting a batch.
8export const NUDGE_AFTER = 3
9// A find that returns at least this many matches gets a "be more specific" hint.
10export const BROAD_FIND = 10
11
12export const PLAYBOOK = `When driving a browser (Claude_Browser, claude-in-chrome or playwright tools):
13- Read before you look: use get_page_text / read_page / find (or playwright browser_snapshot) to read text and get element refs. Take a screenshot only to check layout or visuals, or when the tree lacks the target.
14- For one fact, use find with the exact words you expect (the link text, a label, a phrase from the sentence) instead of get_page_text. Keep find queries specific: a two-word topic can match dozens of elements.
15- get_page_text opens with menus and sidebars; pass max_chars and expect to need find for anything below the fold.
16- Text extraction drops the words inside links ("the study of the , , and"). When those words matter, zoom on the region or take one small screenshot.
17- Click and type by \`ref\` from find/read_page rather than by pixel coordinates whenever a ref exists.
18- After navigate (or any click that loads a new page), pixel coordinates are invalid until you take a new screenshot. Don't follow a navigate with a coordinate click in the same batch: batch navigate + find, then batch the ref click with the steps after it.
19- A ref "outside the viewport" is hidden (a collapsed search box or menu): click the control that reveals it (search icon, menu button) first.
20- Batch: whenever you can predict two or more steps (navigate → click → type → Enter → read), send them in one browser_batch call. End a batch with get_page_text or one screenshot, not one after every step.
21- Screenshots: pass scale 0.5–0.6 unless you need fine detail; use zoom on a region instead of a full-size screenshot.
22- Use form_input to set fields by ref instead of click + select-all + type.
23- Navigate straight to a known URL (search results, deep links) rather than clicking through menus.
24- Don't wait or re-screenshot speculatively; on a slow page use a single short wait, then read.`
25
26export const HINTS = {
27 staleView:
28 'browser-boost: the page changed since your last screenshot, so coordinates are invalid. Instead of taking a screenshot, use find or read_page to get a ref and click by ref.',
29 hiddenRef:
30 'browser-boost: that element is hidden (often a collapsed search box or menu). Click the control that reveals it, such as a search icon or menu button, then retry by ref.',
31 broadFind: (n: number) =>
32 `browser-boost: find returned ${n} matches. Next time use the exact link or button text, or a phrase from the target sentence, so it returns a few.`,
33 batch: `browser-boost: ${NUDGE_AFTER} single browser calls in a row. If you can predict the next steps, send them together in one browser_batch call.`,
34}
35
36/** The input with the default scale added when it is a screenshot without one. */
37export const withScale = (input: Record<string, unknown>) =>
38 input.action === 'screenshot' && input.scale === undefined ? { ...input, scale: SCREENSHOT_SCALE } : input
39
40const isCoordinateAction = (a: Action) =>
41 a.name === 'computer' && a.input?.coordinate !== undefined && a.input?.ref === undefined
42
43/** Index of the first coordinate action that follows a navigate with no screenshot in between, or -1. */
44export const staleCoordinateAction = (actions: readonly Action[]) => {
45 let isStale = false
46 for (const [i, a] of actions.entries()) {
47 if (a.name === 'navigate') isStale = true
48 else if (a.name === 'computer' && a.input?.action === 'screenshot') isStale = false
49 else if (isStale && isCoordinateAction(a)) return i
50 }
51 return -1
52}
53
54// Image tokens are about width × height / 750.
55const imageTokens = (w: number, h: number) => (w * h) / 750
56
57/**
58 * Tokens saved against full-size screenshots, read off each scaled screenshot's
59 * "<scale>-scale view; coordinate frame: WxH" line, whoever set the scale.
60 */
61export const tokensSaved = (text: string) =>
62 Math.round(
63 [...text.matchAll(/([\d.]+)-scale view; coordinate frame: (\d+)x(\d+)/g)].reduce(
64 (sum, [, s, w, h]) => sum + imageTokens(+w, +h) * (1 - Math.min(+s, 1) ** 2),
65 0,
66 ),
67 )
68
69/** The match count of a find result, or 0. */
70export const findMatches = (text: string) => Number(/Found (\d+) match/.exec(text)?.[1] ?? 0)
71
72/** The hints a result's text calls for. */
73export const resultHints = (text: string) => {
74 const hints: string[] = []
75 if (/not screenshotted/.test(text)) hints.push(HINTS.staleView)
76 if (/outside the viewport/.test(text)) hints.push(HINTS.hiddenRef)
77 const n = findMatches(text)
78 if (n >= BROAD_FIND) hints.push(HINTS.broadFind(n))
79 return hints
80}
81
82export const formatStatus = (calls: number, batched: number, saved: number) =>
83 `browser ${calls} calls · ${batched} batched` + (saved > 0 ? ` · ~${(saved / 1000).toFixed(1)}k tokens saved` : '')
84