How PlayGuard works
PlayGuard is an MCP proxy that sits between your AI agent and the tools it drives — Playwright MCP for the browser, a Figma MCP for design files. It exposes one MCP server, and on the way through it revives dead browser sessions, strips 70–90% of the tokens out of accessibility snapshots, and cuts Figma responses down to the part that describes layout. This page explains each of those mechanisms in full: what it does, why it is safe, and which environment variable turns it off.
What an MCP proxy is
The Model Context Protocol (MCP) is how an agent such as Claude Code, Claude Desktop, Cursor, or Codex talks to an external tool. The client spawns a server, the server advertises a list of tools, and every call and response travels over stdio as JSON-RPC.
An MCP proxy is itself an MCP server, but it implements no tools of its own. It spawns the real servers as child processes, forwards their tool lists upward as a single merged catalogue, and passes calls through. Because every request and every response crosses it, a proxy is the one place where you can change what the agent sees without modifying either the agent or the upstream tool.
That is the whole idea behind PlayGuard. Playwright MCP and the Figma MCPs are good at producing complete, correct data. Complete and correct is exactly the problem when the consumer is a language model paying by the token for every byte it reads.
Why Playwright MCP snapshots are expensive
browser_snapshot returns the page's full accessibility tree. On a real application that is routinely 1,500–2,000 lines and 100–300 KB — roughly 25,000 to 75,000 tokens for a single look at a single page. An agent that navigates, looks, clicks, and looks again burns through a context window in a handful of steps.
Most of that payload is unusable by the agent. Static text nodes, decorative images, and presentational wrappers appear in the tree but carry no [ref=] handle, which means the agent cannot click, type into, or otherwise address them. They are read, paid for, and discarded.
The second cost is repetition. Snapshotting the same page twice returns the same hundreds of kilobytes twice, and a click that toggles one button re-sends the entire tree to communicate a two-line change.
Why Figma MCP responses are expensive
A Figma file is a design document, and its API representation carries everything a design tool needs: edit history, plugin data, export settings, prototype interactions, invisible layers left over from iteration, and vector geometry stored as thousands of path coordinates. An agent implementing a component needs none of it.
Repetition is worse here than in the browser. A screen with 100 instances of one button component serializes 100 near-identical subtrees. The information the agent actually needs is one definition plus 99 sets of overrides.
A typical unoptimized node response runs 200–400 KB. After PlayGuard the same response is commonly 60–90 KB, and the log line tells you exactly what happened:
[PlayGuard figma: -68% (284.0KB→91.0KB)]
Architecture
One process, spawned by your agent, that spawns the upstreams itself. Playwright MCP ships as a bundled dependency, so there is nothing to install separately — and nothing to add to your config alongside PlayGuard.
Claude Code / Claude Desktop / Cursor / Codex
│
│ MCP (stdio)
▼
┌─────────────────┐
│ PlayGuard │ ← single proxy for everything
│ │
│ • router │ browser_* → Playwright MCP
│ • recovery │ figma_* → Figma MCP (optional)
│ • optimizer │
│ • cache │
│ • analytics │
└────────┬────────┘
│
┌──────┴──────┐
▼ ▼
Playwright MCP Figma MCP
│
▼
Chromium / Firefox / WebKitFigma is optional. Leave FIGMA_MCP_CMD unset and PlayGuard runs browser-only with no behavioural change — the optimizer never loads.
Snapshot compaction
The compactor keeps every line that carries a [ref=] handle, plus the structural landmarks that give those handles context — nav, main, form, dialog, and the rest. Everything the agent cannot address is dropped, and the reduction is reported inline:
[PlayGuard compact: 312/1840 lines, ~83% removed, 124.3KB→14.1KB]
The guarantee that makes this safe is narrow and checkable: an element the agent could have interacted with before compaction is still there after it. What is lost is prose the agent can retrieve on demand, not capability. Set PLAYGUARD_COMPACT=false to receive the raw tree.
Narrowing a snapshot further
When even the compact tree is large, browser_snapshot takes three optional parameters that restrict the result to one region:
- section — only the landmark subtree whose label matches, for example "form" or navigation "Footer".
- around — only the landmark subtree containing a given [ref=N], once the agent knows which element it cares about.
- depth — cap the depth of the returned subtree, for a high-level overview before drilling in.
A section or ref that does not exist falls back to the full snapshot with an explicit [PlayGuard: … not found] warning rather than returning an empty result. Changing filters between calls always forces a fresh snapshot, so a narrowed request never receives a cached answer that was built for a different filter.
Waiting for a page that is still loading
PLAYGUARD_SMART_WAIT=1 makes a snapshot retry when the page looks mid-load — very few interactive refs combined with a loading, spinner, or skeleton indicator in the text. It retries a bounded number of times and then returns what it has, with a warning. It is off by default because it adds latency to every snapshot call, and most pages do not need it.
Deltas, caching, and prefetch
Compaction attacks the size of one response. These three attack the number of responses.
Delta snapshots
When a page changes only slightly, the agent receives the change instead of the page:
[PlayGuard delta: +3 added, 1 removed, ~91% saved] ADDED: - button "Submit" [ref=47] REMOVED: - button "Loading..." [ref=44]
Past a configurable threshold — PLAYGUARD_DELTA_THRESHOLD, the fraction of lines that must differ — the page has changed enough that a full snapshot is cheaper to read than a diff, and PlayGuard sends one.
Snapshot cache and prefetch
After browser_navigate, PlayGuard fetches a snapshot in the background immediately. By the time the agent asks, the answer is already in memory. A page that has not changed since the last look returns a bare UNCHANGED without touching the browser at all.
Eval cache
Repeated browser_evaluate calls running the same script against the same URL are served from cache within a TTL, and oversized eval output is truncated at a character limit so a stray document.body.innerHTML cannot flood the context window.
Session recovery
Long agent runs kill browsers. A tab crashes, a target closes, the connection is refused — and the agent receives an error it was never designed to handle, halfway through a task, with no memory of how to get back.
PlayGuard matches those failures before the agent sees them:
Target.*closed · Browser.*closed · connect ECONNREFUSED · crashed
On a match it restarts Playwright MCP, restores the last URL, and retries the original call. The agent sees a normal successful response and keeps working. Two calls landing during the same crash produce one restart, and both calls continue — recovery is shared, not duplicated per caller.
Screenshot policy
A screenshot is one of the most expensive things an agent can request, and for most tasks — finding a button, checking a label, filling a form — a snapshot answers the question better. Four modes decide what happens on a screenshot call:
| Mode | Behaviour |
|---|---|
| warn | Default. The screenshot runs; a warning is written to stderr. |
| redirect | Replaced with a snapshot. Pass {visual:true} when pixels are genuinely required. |
| block | Refused with an error that names the variable to change. |
| allow | No restriction. |
redirect is the mode worth adopting: it removes the default cost without removing the capability, because the escape hatch is one argument away.
The Figma optimizer
Figma responses run through eight modules before reaching the agent. Which ones fire depends on the shape your upstream returns — the raw Figma REST API (@figma/mcp) or the pre-simplified { metadata, nodes, globalVars } form that Framelink's figma-developer-mcp produces.
| Module | What it removes |
|---|---|
| Module 1 Metadata cleaner | Fields that say nothing about layout: createdAt, updatedAt, creator, thumbnailUrl, pluginData, sharedPluginData, exportSettings, reactions, interactions. |
| Module 2 Invisible layer pruner | Nodes with visible === false or opacity === 0, recursively. Hidden iteration leftovers are a large share of a working file. |
| Module 3 Component deduplication | Repeated instances of one component collapse to a reference. 100 buttons become one full definition plus 99 entries of { type, name, _ref, overrides }. |
| Module 4 SVG refs | Inline vector geometry (fillGeometry) is replaced with { "_svgRef": "nodeId" } — the shape identity without thousands of path coordinates. |
| Module 5 Top-level metadata trim | Drops metadata.thumbnailUrl (a signed single-use preview URL) and metadata.lastModified from pre-simplified shapes, which Module 1 cannot reach because it only walks document/children. |
| Module 6 Layout compressor | Absolute x/y on nodes inside Auto Layout containers, where position is already determined by layoutMode, gap, and padding. |
| Module 7 Budget trim | If the tree still exceeds FIGMA_TEXT_COMPACT, it is trimmed structurally rather than sliced as text — an over-budget branch becomes an {id, name, type, _stub:true} marker the agent can re-fetch by id. |
| Module 8 Framelink shape optimizer | For the { metadata, nodes[], globalVars.styles } shape, where Modules 2/4/6 find nothing. 8a: sibling subtrees identical but for ids collapse to { id, name, _sameAs }, with a _textDiff map when they differ only in text. 8b: no-op layout styles and the node refs to them are dropped. 8c: float noise is rounded (1.3999999364217122em → 1.4em); node text is never touched. |
Design diff: Figma against the live DOM
playguard_compare_design compares a Figma node with the DOM element that implements it and reports the differences numerically — no screenshots, no side-by-side eyeballing.
[PlayGuard design diff: 42:1337 → [data-testid="login-btn"] 2 mismatches, 6 matches] fontSize: Figma 16px → Browser 14px ⚠ MISMATCH (+2.0px) color: Figma rgb(255, 255, 255) → Browser rgb(255, 255, 255) ✓ MATCH backgroundColor: Figma rgb(25, 118, 210) → Browser rgb(25, 118, 210) ✓ MATCH padding: Figma 12px 24px → Browser 12px 16px ⚠ MISMATCH (Δright:+8px, Δleft:+8px) borderRadius: Figma 8px → Browser 8px 8px 8px 8px ✓ MATCH
It runs in three modes:
| Mode | Arguments | Use |
|---|---|---|
| Single | figmaNodeId + browserSelector | One element. |
| Batch | pairs: [{figmaNodeId, browserSelector}, …] | Several at once; one bad selector does not sink the rest. |
| Auto-map | figmaNodeId + autoMap: true | Point at a component; layers beneath are matched by data-figma-id, data-testid, id, class, or exact text. |
The rules that keep the comparison honest
- Properties are chosen per node. A TEXT layer is compared on typography and colour; a container on background, padding, radius, and shadow. Pass properties[] to override.
- Box values compare side by side. 8px 8px 0 0 never passes as 8px, because padding, margin, radius, and border width are compared per side rather than as a collapsed string.
- Unset is not unknown. A property Figma leaves unset but CSS zero-defaults — no shadow, no border — is still checked, so a stray browser value gets caught. margin is the one exception: Figma has no margin concept, so it is reported as unknown rather than as a defect.
- Typography follows the label. A button's font size is read from its TEXT layer at any depth, and its background is never mistaken for its text colour.
- Viewport-dependent values are never auto-selected.width and height depend on the viewport, not the design; request them explicitly and a warning rides along.
- Auto-map never guesses. A candidate selector that matches zero or several elements is reported as unmapped rather than silently attached to the wrong element.
- A missing node is an error, not a substitute. A figmaNodeId the response does not contain fails that pair, instead of falling back to whatever root frame happens to be present.
Tolerances are configurable: sizes match within PLAYGUARD_DESIGN_DIFF_TOLERANCE_PX, colours within PLAYGUARD_DESIGN_DIFF_TOLERANCE_COLOR per RGB channel.
Setting it up
Nothing to install. npx fetches the package on first run, and Playwright MCP comes bundled. Node 18 or newer.
Browser only
{"mcpServers": {"playguard": {"command": "npx","args": ["-y", "playguard"],"env": {"PLAYGUARD_SCREENSHOTS": "redirect"}}}}
Browser and Figma
{"mcpServers": {"playguard": {"command": "npx","args": ["-y", "playguard"],"env": {"PLAYGUARD_SCREENSHOTS": "redirect","FIGMA_MCP_CMD": "npx @figma/mcp","FIGMA_API_KEY": "your-figma-api-key","FIGMA_CACHE_TTL": "60000"}}}}
Claude Code reads ~/.claude/claude_desktop_config.json or your project's .claude/settings.json. Claude Desktop uses the same JSON at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %APPDATA%\Claude\claude_desktop_config.json on Windows. Cursor, Codex, and any other MCP client take the same server entry.
On Windows, use forward slashes inside JSON strings. Spaces in paths need no escaping.
Configuration reference
Every behaviour on this page has a switch. The defaults are the recommended setup; these exist so that nothing PlayGuard does is something you cannot turn off.
Browser
| Variable | Default | Description |
|---|---|---|
| PLAYGUARD_SCREENSHOTS | warn | allow / warn / block / redirect. |
| PLAYGUARD_COMPACT | true | Set false to receive the raw accessibility tree. |
| PLAYGUARD_DELTA | true | Set false to disable delta snapshots. |
| PLAYGUARD_DELTA_THRESHOLD | 0.4 | Fraction of lines that must change (0–1) before a full snapshot is sent instead of a delta. |
| PLAYGUARD_TOKEN_BUDGET | 0 | Max tokens per snapshot. Truncates on a line boundary so a [ref=] is never split. 0 = off. |
| PLAYGUARD_HINT_THRESHOLD | 4 | After N consecutive snapshots with no action taken, inject a hint listing the available interactive refs. |
| PLAYGUARD_PREFETCH_SNAPSHOT | true | Set false to disable the background snapshot fetched after browser_navigate. |
| PLAYGUARD_SMART_WAIT | false | Set 1 to retry a snapshot that looks mid-load instead of returning it as-is. |
| PLAYGUARD_SMART_WAIT_MS | 1000 | Delay between smart-wait retries, in ms. |
| PLAYGUARD_SMART_WAIT_MAX_RETRIES | 3 | Retries before giving up and returning the snapshot with a warning. |
| PLAYGUARD_SMART_WAIT_MIN_REFS | 5 | A snapshot with at least this many refs is never treated as still loading. |
| PLAYGUARD_EVAL_CACHE_TTL | 500 | browser_evaluate cache TTL in ms. 0 = off. |
| PLAYGUARD_EVAL_COMPACT | 10000 | Max characters of eval output. 0 = off. |
| PLAYWRIGHT_MCP_CMD | bundled | Override the Playwright MCP command. |
| PLAYWRIGHT_MCP_ARGS | — | Extra arguments for Playwright MCP, space-separated; quote an argument containing a space. --output-dir here takes precedence over PLAYGUARD_OUTPUT_DIR. |
| PLAYGUARD_OUTPUT_DIR | .playguard/ | Where on-disk artifacts go, split by origin: .playguard/.qa/ for the site under test, .playguard/.figma/ for Figma image exports. |
| PLAYGUARD_PROJECT_ROOT | nearest repo root | The root that .playguard/ is created in. process.cwd() is whatever the client spawned the server with, so it is not trusted — set this to pin the location. |
| PLAYGUARD_LOG_DIR | logs/ | NDJSON analytics log directory. |
Figma
| Variable | Default | Description |
|---|---|---|
| FIGMA_MCP_CMD | — | Figma MCP launch command. Unset means Figma support is off entirely. |
| FIGMA_MCP_ARGS | — | Extra arguments for the Figma MCP, space-separated; quote an argument containing a space. |
| FIGMA_CACHE_TTL | 0 | Figma response cache TTL in ms. 0 = off. |
| FIGMA_SVG_REFS | true | Set false to keep SVG geometry inline. |
| FIGMA_TEXT_COMPACT | 10000 | Max characters for the Figma response. Over budget, the tree is trimmed structurally by Module 7. 0 = off. |
| FIGMA_API_KEY | — | Forwarded to the Figma MCP child process, and used nowhere else. |
Design diff
| Variable | Default | Description |
|---|---|---|
| PLAYGUARD_DESIGN_DIFF_TOLERANCE_PX | 2 | Size difference, in px, still counted as a match. |
| PLAYGUARD_DESIGN_DIFF_TOLERANCE_COLOR | 5 | Per-channel RGB difference still counted as a match. |
Measuring it on your own workload
The 70–90% figure is a range because it depends on your pages. Rather than trusting it, measure it: every tool call is written to logs/YYYY-MM-DD.ndjson with raw and kept byte counts, and npm run analyze turns your own logs into a report.
── Snapshot token savings ──────────────────────────────── Cache hits: 12/47 snapshots (8 from prefetch) Bytes saved by cache: 84.3 KB (~21 075 tokens) Bytes saved by compact: 312.1 KB (~78 025 tokens) Total saved: 396.4 KB (~99 100 tokens) Reduction vs raw: 83% (82.1 KB sent vs 478.5 KB without PlayGuard)
Each NDJSON line carries the full detail for your own analysis — for browser calls rawBytes, keptBytes, savedBytes, delta, cacheHit, prefetchHit; for Figma calls inBytes, outBytes, savedTokens, and a count for each module that fired.
npm run bench measures the other side of the trade: proxy overhead, snapshot versus screenshot size, cache hit rate, and crash recovery time, with no model in the loop. Overhead lands at 1–3 ms per call.
What it does not do
- It does not phone home. PlayGuard runs locally over stdio and talks to no server of its own. The logs are files on your disk.
- It does not hold your keys. FIGMA_API_KEY is forwarded to the Figma MCP child process exactly as you set it, and is used nowhere else.
- It does not drop what the agent can act on. Compaction preserves every [ref=]; budget trimming leaves re-fetchable stubs.
- It is not a black box. MIT licensed, source on GitHub, and CI runs the full suite on every push and pull request — the compactor, all eight Figma optimizer modules, crash detection, caching, snapshot filtering, and design-diff extraction each have tests.