A portable, project-agnostic AI design assistant. A browser extension adds an element-picker to
your running app and a chat panel in the browser's side panel. Click an element or type a request; a
tiny local shim drives claude headless against your repo; your dev server hot-reloads; the
change appears live β with zero changes to the target project's source.
The shim runs claude the same way your editor already does (headless, streaming) and reuses your
existing ~/.claude login β no API key, no Docker, no reverse proxy. The browser reaches the
shim over VS Code's built-in port forwarding, so the exact same setup works whether your code
is on the laptop or on a remote server, on Windows / macOS / Linux.
Two deliverables:
shim/β a cross-platform Node CLI: runsclaudein a repo and streams the run as SSE on127.0.0.1:<port>.extension/β a Manifest V3 browser extension: the universal, framework-agnostic UI.
Slide.Write.Demo.mp4
I loved the DOM element picker paired with a Claude Code chat in claude.ai/design, but I needed a tool that worked against a local development environment. Along the way I realized the same tool could also generate and embed image assets.
This README is self-contained and buildable: it inlines every contract, the shim's core code, and the extension spec. It's written to be implemented by Claude Code β see Β§12.
- What you get
- Repository layout
- Architecture
- Why this reaches everywhere
- The shim (
shim/) - The SSE event contract
- The element-capture contract
- The browser extension (
extension/) - Discovery & routing
- Security model
- Quick start
- Build order for Claude
- Fallback: public-hostname access via a reverse proxy
- Prior art
- A browser extension with a toolbar button / shortcut that opens a chat panel in the browser's
side panel (docked beside the page, which reflows to make room). Type a prompt ("make the
primary button green") β the shim drives
claudeβ the running app hot-reloads β you see it in seconds. - A Markup mode: hover to highlight elements, click one to describe a change anchored to it; the
clicked element's context (tag, classes, text, DOM path) is sent so
claudefinds the source. Picks stack β the picker stays armed, so consecutive clicks add elements (up to 5 per message), each shown as its own removable chip; π― again or Esc finishes picking. - The panel streams thinking, tool calls, file edits, tool output, a final summary, and run stats live over SSE, and shows the commit each run makes.
- Reusable across projects. The shim is generic; per-project knowledge comes from the target
repo's own
CLAUDE.md. Adding it to a project is: run the shim pointed at the repo, enable the origin in the extension. No edits to the project's source.
Single-developer, trusted-local tool (Β§10).
.
βββ README.md # this spec
βββ CLAUDE.md # guidance for implementing/extending this repo
β
βββ shim/ # the local agent: claude β SSE on loopback
β βββ package.json # bin: "slide-write"; dep: @anthropic-ai/claude-agent-sdk
β βββ slide-write.mjs # the CLI (HTTP server + SDK run loop + SSE mapping + commit)
β βββ slide-write.py # stdlib-only Python port for hosts without Node (Β§5.3)
β
βββ extension/ # the Manifest V3 browser extension (the universal UI)
βββ manifest.json # side_panel + <all_urls>; content script (picker) on localhost
βββ background.js # config store (chrome.storage); messaging; opens the side panel
βββ sidepanel.html # the side-panel document (loads styles.css + sidepanel.js)
βββ sidepanel.js # side-panel host: per-active-tab config, SSE, picker bridge (Β§8.4)
βββ content/
β βββ inject.js # content script: picker bridge only β arms picker, crops shots (Β§8.4)
β βββ picker.js # capture-phase element picker (Β§8.3)
β βββ panel.js # chat transcript + composer (renders the Β§6 events; runs in the panel)
β βββ sse.js # fetch + getReader SSE reader (Β§8.2, verbatim)
βββ options.html
βββ options.js # per-origin config: enabled + token + shim URL
βββ styles.css # the side-panel document's styles
No Docker, no compose overlay, no reverse-proxy config β the shim is a plain process and the transport is VS Code's port forwarding.
The shim binds loopback on the machine where the code lives (your laptop, or a remote
server/WSL/container). VS Code's port forwarding makes that loopback port β and your app's dev
port β appear at localhost:<port> on the machine where the browser runs. So the extension always
talks to http://localhost:<port>, and local dev and remote dev look identical to it.
%%{init: {'theme':'neutral', 'flowchart': {'diagramPadding': 16, 'nodeSpacing': 55, 'rankSpacing': 75, 'padding': 14, 'subGraphTitleMargin': {'top': 6, 'bottom': 14}}}}%%
flowchart TB
subgraph UI["UI machine β Win11 / macOS / Linux desktop (where the browser runs)"]
direction TB
PAGE["app page Β· http://localhost:5173<br/>content script: element picker"]
EXT["browser side panel<br/>chat panel + SSE reader + picker bridge"]
PAGE -.- EXT
end
subgraph CODE["Code host β same laptop, OR a remote server / WSL / container"]
direction TB
SHIM["slide-write shim Β· 127.0.0.1:4040<br/>HTTP + SSE"]
CLAUDE["claude (headless, Agent SDK)<br/>reuses ~/.claude Β· runs as you (non-root)"]
DEV["app dev server Β· Vite, β¦ (unchanged)"]
REPO[("repo")]
SHIM --> CLAUDE
CLAUDE -- "edits files" --> REPO
REPO -- "file watch β HMR" --> DEV
end
EXT == "fetch + SSE β http://localhost:4040/design<br/>(VS Code port-forwards 4040 + 5173 to localhost)" ==> SHIM
DEV == "HMR Β· hot-reloaded UI" ==> PAGE
classDef hot fill:#bdbdbd,stroke:#404040,color:#111111;
classDef repo fill:#9e9e9e,stroke:#212121,color:#111111;
class SHIM,CLAUDE hot;
class REPO repo;
The load-bearing property: the shim and the dev server share the repo through the filesystem.
claude writes files; the project's own dev server sees the change and hot-reloads. The shim never
imports the project's code.
The diagram above is the default path β the app opened at a VS Code-forwarded localhost URL.
To serve it at a public hostname like https://app.example.com instead, see the reverse-proxy
variant in Β§13.
VS Code forwards loopback ports identically in every remote mode and on every OS β that's the
entire trick. Bind the shim to 127.0.0.1, open your app via its forwarded localhost URL, and:
| Setup | Where the shim + code live | How the browser reaches the shim |
|---|---|---|
| Local (Win / macOS / Linux) | the laptop | direct localhost:4040 (forwarding is a no-op) |
| Remote-SSH β a Linux server | the server | VS Code forwards over its SSH channel |
| Windows + WSL | WSL | VS Code forwards WSL β Windows localhost |
| Dev Container | the container | VS Code forwards container β host |
| Codespaces / Tunnels | the cloud VM | VS Code's forwarded URL |
From the browser's point of view everything is localhost, so there's one extension code path
and one shim β no topology awareness, no per-OS install. The one habit that keeps it uniform: in
remote dev, open the app through its VS Code-forwarded localhost URL, not a public hostname.
(If you must use a public hostname, see the reverse-proxy fallback.)
Credential portability is the claude CLI's job: the shim runs claude (via the Agent SDK), which
already knows where your login lives on each OS β so reusing your subscription works the same on
Windows, macOS, and Linux with no API key.
A small Node CLI. It serves HTTP+SSE on loopback and drives claude headless via the Agent SDK.
Because it runs as you (non-root), it can use permissionMode: "bypassPermissions" directly β
no permission-prompt callback, no root workarounds. Edits are written as your user, so they're
yours.
{
"name": "slide-write",
"version": "0.1.0",
"type": "module",
"bin": { "slide-write": "slide-write.mjs" },
"dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.168" }
}The SDK reuses ~/.claude automatically (or ANTHROPIC_API_KEY if set). Run with
node shim/slide-write.mjs β¦, or npm i -g ./shim then slide-write β¦, or npx.
(Equivalent zero-dep alternative: shell out to claude -p --output-format stream-json and reframe
its JSONL as SSE β same event mapping. The SDK is used here for typed messages.)
A single .mjs file. CLI flags / env
(--port/--repo/--token/--origin/--bind/--model/--debug/--use-skills, the multi-host
flags --repo-root/--repos, the image flags
--gemini-key/--gemini-model/--image-instructions, plus the provider flags
--codex-bin/--codex-home and --grok-bin/--grok-home β see Β§5.4) configure it; it stands up an
http.createServer on 127.0.0.1 (overridable via --bind for Β§13 only), and the run logic lives
in exported runDesign(body, emit, aborted, signal, repo) / runImage(body, emit, aborted, signal, repo) so the HTTP handler and tests share one implementation (the server only starts when the file
is run directly, behind an import.meta.url guard).
Multi-host mode (for the Β§13 proxy fallback): passing --repo-root <dir> and/or --repos host=path,β¦ makes the shim serve MANY repos, resolving the target per request from the Host
header: explicit --repos entry first, then localhost/127.0.0.1 falls back to --repo
(so VS Code-forwarded access keeps working), then the host's first DNS label is looked up under
--repo-root (life-ops.dev.example.com β <root>/life-ops; the label is sanitized β
a Host header is attacker-controlled text, never a path). Unmapped hosts get 404 on every route
but /health. The busy lock is per-repo: two projects can run concurrently, a second run on the
same repo is still rejected. Without these flags the shim is single-repo and ignores Host
entirely β the original behavior.
The system prompt is the interesting part β it's what makes a generic shim behave well against
any repo. It's deliberately project-agnostic (per-project knowledge comes from the target's own
CLAUDE.md, loaded via settingSources: ["project"]):
Rendered, the system prompt reads:
You are editing a web app live from within its running dev environment. Your edits land on the repo at the working directory and the app's own dev server hot-reloads, so changes appear in the browser within seconds.
FIRST, read the repo's CLAUDE.md / AGENTS.md (and README) for THIS project's conventions β where styling lives, where components/screens live, the framework in use. Follow them.
- Make the SMALLEST focused change that satisfies the request, in the spirit of the existing code.
- Reuse existing tokens/components/patterns; don't add dependencies unless asked.
- Do NOT edit Dockerfiles, CI, or anything under .claude / .env / credentials.
- Keep schema/model changes ADDITIVE; never rename, drop, or retype an existing table or column.
- When done, reply with one or two sentences describing exactly what you changed.
The verbatim source string lives at PREAMBLE in shim/slide-write.mjs (and the Python port in
shim/slide-write.py); keep this prose in sync with both if you edit it.
The other load-bearing bit is how the clicked elements become prompt context. A request may
carry up to 5 stacked targets (Β§7 elements; the
elementsOf() helper normalizes β legacy single element accepted β and re-caps server-side).
buildPrompt() joins the typed request with the current screen and, per element, a compact JSON
of its tag/class/text/domPath, then instructs claude to use those to locate the source:
parts.push(`\n[The user clicked this on-screen element${nth(i)} and is referring to it]\n` +
JSON.stringify(ctx, null, 2) +
"\nUse the class names / text / DOM path to locate the source and matching styles, then edit there.");nth(i) numbers the blocks (" (element 2 of 3)") only when several were picked, so the
single-element prompt is unchanged.
When an element carries a screenshot (Β§7 screenshotDataUrl),
runDesign writes it to a temp PNG outside the repo (saveScreenshot(), the same temp-file
pattern runImage uses; one file per element, index-suffixed so same-millisecond picks don't
collide) and buildPrompt appends a line telling claude to Read that path β its Read tool
renders the image, so the agent sees how the element looks before editing. No Agent-SDK
image-input plumbing is needed. Clipboard-pasted images (Β§7
pasted) ride the same elements/screenshotDataUrl temp-file path; buildPrompt only swaps in
neutral "[The user pasted this imageβ¦]" wording keyed off the pasted flag.
Everything else is mechanical and may be implemented freely as long as it honors these contracts:
- The SDK run loop.
query({ prompt, options })is driven withcwd: REPO,permissionMode: "bypassPermissions"plusallowDangerouslySkipPermissions: true(the SDK requires both; the shim runs as you/non-root, so no permission callback is needed),includePartialMessages: true,settingSources: ["project"],systemPrompt: PREAMBLE,maxTurns: 40. The loop dispatches onm.typeand maps each SDK message to a Β§6 SSE event βsystem/initβstart,content_block_deltaβdelta/thinking_delta,tool_useβfile_edit(for Edit/Write/MultiEdit/NotebookEdit) ortool,tool_resultβtool_result,resultβresult; assistant-message usage (deduped by message id, seeded early bymessage_start) plussystem/thinking_tokensβ the cumulativeusageevent (Β§6 "Live token usage").--debug/SW_DEBUGlogs eachm.type/m.subtypeto stderr. Bail immediately whenaborted()(client disconnect) returns true.--use-skills/SLIDEWRITE_USE_SKILLSaddsskills: "all"so the target repo's.claude/skills/are loaded (off by default βsettingSources:["project"]alone does not enable skills). - Auto-commit only what this run changed. Snapshot
git status --porcelain -uallbefore the run, diff after,git add+ commit just the new paths under aSlide Writeidentity (no push), emitcommit. Parse porcelain untrimmed (line.slice(3)) β the leading status-column space is significant, so a path's first character would be eaten by a trimming helper. A request may opt out with a top-levelautoCommit: false(Β§6) β the run then leaves its edits uncommitted in the working tree and nocommitevent fires; absent/anything-else keeps the commit. - HTTP server. CORS (allow only
ORIGIN; includeAccess-Control-Allow-Private-Network), aBearer <token>gate on every route except/health(401 first), thebusysingle-run lock, and routes/health,/meta,/history,/history/<id>,POST /design,POST /generate-image.
Validated against @anthropic-ai/claude-agent-sdk 0.3.168. Message/block shapes (stream_event
deltas, tool_use/tool_result blocks) can drift across SDK versions; the loop dispatches on
m.type defensively β verify against the installed SDK during the phase-1 smoke test
(Β§12).
A stdlib-only Python 3.10+ port of slide-write.mjs for hosts where Node isn't available. Same
HTTP+SSE surface, same flags/env, same Β§6/Β§7 contracts β the extension can't tell the two apart.
Instead of the Agent SDK it shells out to the claude CLI headless (the zero-dep alternative Β§5.1
mentions): claude -p --output-format stream-json --verbose --include-partial-messages --dangerously-skip-permissions --setting-sources project --system-prompt <PREAMBLE> --max-turns 40,
with the prompt fed on stdin and the JSONL reframed as Β§6 SSE β the CLI's stream is the same message
stream the SDK yields, so the mapping mirrors the .mjs line for line. claude itself needs no
Node either: the native installer (curl -fsSL https://claude.ai/install.sh | bash) ships a
self-contained binary that reuses the same ~/.claude login.
python3 shim/slide-write.py --repo <path> --port 4040 --origin http://localhost:5173 --token <secret>
Differences from the Node shim (all invisible to the client):
--claude-bin <path>/SLIDEWRITE_CLAUDE_BIN(extra flag) β theclaudebinary to drive, for environments where it isn't onPATH(e.g. a bare systemd unit).- Skills parity is inverted at the CLI: the CLI loads skills by default (the SDK doesn't), so
without
--use-skillsthe shim passes--disable-slash-commandsto match the Node shim's no-skills default. - SSE framing is close-delimited (
Connection: close;BaseHTTPRequestHandlerdoesn't chunk) rather than Node's chunked encoding β the Β§8.2 fetch-reader handles both identically. - Client disconnect is detected by a failed SSE write or a socket EOF peek between stream
messages; either kills the
claudechild. The in-flight Gemini call has no abort signal (urllib) β a disconnect during generation is noticed right after it returns.
Validated against claude CLI 2.1.173 β same drift caveat as the SDK: re-verify the stream-json
shapes on upgrade. Keep this file in lockstep with slide-write.mjs (the .mjs is the reference
implementation; both must change together with the contracts).
The shim can drive additional agentic backends selected by body.provider. Each is another
producer of the same Β§6 SSE events, dispatched by runAgent/run_agent. Unknown provider
ids hard-error (no silent Claude fallthrough); omit/"anthropic" still defaults to Claude.
When a request carries provider: "openai", the agent step runs
codex exec --json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -C <repo> [-m <model>] [-c model_reasoning_effort="<effort>"]
instead of the SDK/CLI. The prompt (PREAMBLE + the built Β§7 context) is fed on stdin; codex's JSONL
event stream is mapped by streamCodex / stream_codex:
thread.startedβstart(itsthread_idis the session id, used for resume)item.completedwithfile_changeβfile_edit(one per changed path) Β·agent_messageβdeltaΒ·reasoningβthinking_deltaΒ·command_executionβtoolturn.completed.usageβ the cumulativeusageevent (input_tokens,cached_input_tokensβcacheReadTokens,reasoning_output_tokensβthinkingTokens)error/turn.failedβ marks the run errored; stream end emitsresult.
Auth reuses codex's own login β no API key here. codex reads CODEX_HOME/auth.json (default
~/.codex), the same ChatGPT-oauth credentials the codex CLI logs in with. The shim never handles
the token for running a design; it only reads it to list models (below).
Extra flags (both shims): --codex-bin <path>/SLIDEWRITE_CODEX_BIN (default codex) and
--codex-home <dir>/SLIDEWRITE_CODEX_HOME (default unset β codex's own ~/.codex). Validated
against codex CLI 0.144.1; codex exec --json is the headless equivalent of
claude -p --output-format stream-json. Resume threads a prior codex session via
codex exec β¦ resume <thread_id> - (the - reads the continuation prompt from stdin).
GPT-6 Astra (gpt-6-astra) is supported through this OpenAI provider in both shims.
OpenAI lists Astra for Codex CLI and API access;
availability depends on the signed-in account, client, and rollout. Slide Write discovers it from
the account's model endpoint and exposes the returned reasoning options in the side panel.
Choose OpenAI (Codex) in the origin's extension options, then GPT-6-Astra in the model
dropdown. Close and reopen an already-open side panel to refresh its model list. If Astra is
absent, check the shim host's codex --version and /model picker with the same login.
A direct POST /design request can select it with:
{"provider":"openai","model":"gpt-6-astra","effort":"low","prompt":"Make the primary button green"}Validated with Codex CLI 0.153.4: both shims listed Astra through authenticated /meta and
completed a live Astra /design smoke test (start β delta β usage β result β done).
When a request carries provider: "grok", the agent step runs the host grok CLI headless:
grok --prompt-file <tmp> --cwd <realpath(repo)> \
--output-format streaming-messages-json --include-partial-messages \
--always-approve --permission-mode bypassPermissions --sandbox off \
--no-auto-update --max-turns 40 [-m <model>] [--effort <effort>] [-r <session-id>]
Prompt delivery: PREAMBLE + the built Β§7 context are written to a temp file outside the repo
($TMPDIR/slidewrite-grok-prompt-<ts>-<rand>.txt) and passed via --prompt-file β never as a
giant -p argv string (real multi-element prompts can blow ARG_MAX). The temp file is unlinked
when the child exits (or on abort).
Live stream mapping (streamGrok / stream_grok) reuses the Claude Messages/stream-json
shapes (validated against grok CLI 1.0.3; a redacted fixture lives at
shim/fixtures/grok-streaming-messages-edit.jsonl β CLI help is authoritative for formats when
docs.x.ai tables lag):
system/subtype:initβstart(session_id,model)stream_event/content_block_delta/text_deltaβdelta;thinking_deltaβthinking_deltaassistanttool_usewith namesearch_replaceorwriteβfile_edit(input.file_path); other tools βtoolusertool_resultβtool_resultresultβresult(is_error, usage, β¦)
Auth is host-only β grok login β ~/.grok/auth.json, or inherit XAI_API_KEY on the shim
process. There is no extension-stored coding key and no --xai-key argv flag (secrets must
not appear in process listings).
--cwd and history keys use realpath(repo) in both shims (fs.realpathSync /
os.path.realpath) so Node and Python cannot diverge under symlinks. Encoded cwd is the session
storage key under ~/.grok/sessions/<encodeURIComponent(absRepo)>/<uuid>/.
Extra flags (both shims): --grok-bin <path>/SLIDEWRITE_GROK_BIN (default grok) and
--grok-home <dir>/SLIDEWRITE_GROK_HOME (default unset β grok's own ~/.grok / $GROK_HOME).
Resume uses -r <session-id> when resume is a valid UUID.
/meta discovers each provider's chat models when the side panel opens; there are no hard-coded
chat-model lists or client fallbacks. The Node shim asks an empty-input Agent SDK query for
supportedModels(), while the Python shim sends Claude CLI an initialize control request and
reads response.models. Both use the normal Claude Code login and consume no model turn; the result
reflects that shim's executable, account entitlements, and managed model policy. Returned effort
levels are advertised with a default choice that leaves Claude Code's configured effort untouched.
OpenAI discovery calls GET https://chatgpt.com/backend-api/codex/models?client_version=<v> with
Authorization: Bearer <access_token> (from CODEX_HOME/auth.json), chatgpt-account-id, and
originator: codex_cli_rs; <v> comes from codex --version. The listable, API-supported entries
and their reasoning levels are normalized to the same {id,label,description?,efforts,defaultEffort}
shape.
Grok discovery prefers a short grok models refresh, then reads $GROK_HOME/models_cache.json
(reasoning_efforts β the same model shape). Empty list means not logged in / CLI missing β the
extension surfaces a status hint; Send is not hard-blocked (parity with OpenAI).
Why a list is empty. A provider entry whose discovery failed also carries error β the
human-readable cause β and, where a re-login is the cure, fix: the one shell command that repairs
it (codex logout && codex login, grok login, claude auth login). Both keys are omitted when
there is nothing to report, so a healthy entry stays byte-identical for older clients, and an empty
list with no error remains a legitimate answer (an account with no entitlements). The cases the
shims distinguish: OpenAI β no CODEX_HOME/auth.json (never signed in) vs. no access token in it
vs. HTTP 401 from the models endpoint vs. another HTTP status vs. an unreachable endpoint;
Grok β a missing grok binary (--grok-bin) vs. a missing/unreadable models_cache.json (not
logged in) vs. a cache listing no models; Anthropic β a failed/timed-out discovery handshake. The
401 case is worth spelling out because it is terminal, not transient: codex rotates that access token
silently in normal use, so a 401 reaching the shim means the refresh token is spent or revoked as
well (plain codex exec is equally broken) and only a full re-login fixes it. The side panel puts
error in its status line and raises the diagnostics banner β "No OpenAI models available" β with
fix shown as the command to run on the code machine, instead of leaving a silently empty dropdown
as the only symptom.
There is no shim cache of discovery results beyond what each CLI maintains: the panel fetches /meta
once as it opens (and when that open panel changes origin/connection), while its liveness timer
polls only /health. A provider discovery failure yields an empty list β plus the error/fix above
β rather than stale model names.
Every frame is one JSON object on a data: line; the client reads only data:. Each has a
type. This is the interface between shim/ and extension/.
type |
payload | client action |
|---|---|---|
start |
{sessionId, model} |
status row "Started Β· <model>" |
delta |
{text} |
append to the current assistant bubble |
thinking_delta |
{text} |
append to the current thinking bubble |
usage |
{inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, thinkingTokens} |
cumulative live token counter β spinner + count in the status bar |
tool |
{tool, detail, id} |
compact tool row (detail = command/file/pattern) |
file_edit |
{tool, path, id} |
edit row (βοΈ path) |
tool_result |
{tool, id, text, isError, truncated} |
collapsible output (auto-open on error) |
result |
{isError, numTurns, durationMs, totalCostUsd, usage, result} |
stats footer; result is the final-text fallback if no deltas streamed |
commit |
{sha, count} |
green "Committed <sha> Β· N files" |
commit_error |
{message} |
red error row (commit didn't land β edits remain in the working tree) |
image_status |
{state} |
status row (state:"generating" β "generating imageβ¦"); emitted only by /generate-image |
image_generated |
{tmpPath, mimeType, bytes} |
note row "πΌοΈ image generated"; metadata only (no image bytes over the wire) |
error |
{message} |
error row |
done |
{} |
end of stream; clear the busy indicator; if the per-origin auto-reload on save option is on and any file_edit fired this run, reload the page (for apps without hot-reload) |
Adding a new type is backward-compatible: clients ignore unknown types.
| user | {text} | user bubble (history replay only; live runs render the user bubble inline in send()) |
The user event is emitted only by GET /history/<id> (below), not by a live /design run.
Live token usage (additive). During a run the shim emits cumulative usage events so the client
can show a running token counter while claude works. The authoritative per-API-call usage arrives
with each SDK assistant message β accumulated deduped by message id (partials and multi-block
messages repeat the same usage; message_start stream events seed an entry early with the
input/cache counts). Between turns, the SDK's system/thinking_tokens estimate fills the gap while
the model thinks: it accrues into thinkingTokens and resets when the next authoritative usage
lands (whose output_tokens already includes that thinking β so the client's live count is
outputTokens + thinkingTokens without double-counting). Thinking-driven emits are throttled
(~250ms); turn-boundary emits are immediate. The final result event's usage/totalCostUsd
remain the authority for the stats row β there is no streamed mid-run cost, so cost shows only at
the end. usage is live-only: it is not persisted to transcripts and GET /history/<id> does not
replay it.
Model and effort selection (additive). /design accepts optional top-level model and effort
fields selected from the provider metadata fetched when the panel opens. Anthropic receives the
model through the Agent SDK / Claude CLI and effort through the SDK effort option / CLI --effort;
choosing default omits the effort override. OpenAI receives -m <model> and
-c model_reasoning_effort="<effort>". Grok receives -m <model> and --effort <effort>. The
provider CLIs remain authoritative if a caller bypasses the UI and submits an invalid value. The
extension renders adjacent model/effort dropdowns and persists both choices per-origin. The model
actually used is echoed in the start event.
Provider selection (additive). /design and /generate-image also accept an optional top-level
provider β "anthropic" (default/absent), "openai", "grok", or "google". /meta advertises
the choices as
providers: [{ id, label, enabled, models: [{id,label,efforts?,defaultEffort?}], defaultModel, error?, fix? }]
plus defaultProvider,
and the top-level models/defaultModel expose the discovered Anthropic list for backward compatibility. The
extension's options page picks the provider per-origin; the side-panel model dropdown then shows that
provider's models. anthropic runs the claude path above; openai runs the codex path and
grok runs the Grok Build CLI path (Β§5.4);
google is a reserved placeholder advertised enabled:false that returns an error event if
invoked. Unknown provider ids also return error (no silent Claude fallthrough). The extension
blocks Send when the selected provider is missing or enabled:false in /meta.providers
(guards new-extension + old-shim combinations). Everything downstream of the agent step (SSE
contract, the porcelain-diff auto-commit, done) is provider-agnostic.
Image generation (additive β Gemini "nano banana"). POST /generate-image is an SSE route
(same Bearer+CORS gate, same busy lock and per-run auto-commit as /design). It takes
{ imagePrompt, elements, geminiKey, imageInstructions, screen, model, autoCommit } (elements as
in Β§7 β β€5 targets, legacy single element accepted). The shim calls Google's
Generative Language API (model id from --gemini-model/SLIDEWRITE_GEMINI_MODEL, default
gemini-2.5-flash-image) with the key in an x-goog-api-key header β never the URL, so it can't
leak into logs. If an element's imageDataUrl is present (the user picked an <img>; the picker captured
its pixels via canvas), it's sent as an inline image part for image-to-image β the first element
carrying pixels wins, since Gemini takes one source image; otherwise it's
text-to-image. The decoded image is written to a temp file outside the repo, an image_generated
event fires, and the shim then drives claude (via the normal Β§6 stream) to copy the asset into the
project and wire it into the picked element(s). Key resolution: body.geminiKey β
--gemini-key/GEMINI_API_KEY; missing β an error event. /meta advertises
{ geminiModel, geminiEnv } (geminiEnv = the shim has a server-side key). The extension stores one
global Gemini key (shared across origins).
Where image-save conventions live (precedence lowβhigh). Save path, naming, resizing, DB/CDN steps differ per project, so they belong in the target repo, not the browser:
CLAUDE.md## Image assetssection β the default; always in context, deterministic, no code change. The shim's image prompt already says "follow the project's image conventions.".claude/skills/image-asset/SKILL.mdβ for procedural handling (resize with a bundled script, multi-dir, insert amediarow, push to a CDN). A Skill can ship scripts and is model-invoked by itsdescription. Requires running the shim with--use-skills(orSLIDEWRITE_USE_SKILLS=1) βsettingSources:["project"]alone does not enable skills; that flag passesskills:"all"to the SDK query (and applies to/designtoo). Use a Skill when the procedure is large/reusable; use CLAUDE.md when it's just a path/naming rule.- Per-origin βImage steps (override)β β the extension field (sent as
imageInstructions, appended last, wins). A per-developer override / quick experiment, not the source of truth. Falls back to--image-instructions/SLIDEWRITE_IMAGE_INSTRUCTIONS.
Minimal .claude/skills/image-asset/SKILL.md in a target repo:
---
name: image-asset
description: Save a generated/provided image into this app and reference it. Use whenever an image
file needs to be added to the project and wired into a component.
---
- Put images in `src/assets/generated/`, kebab-cased from the prompt, `.webp` when possible.
- Resize to max width 1024 with `node scripts/resize.mjs <file>`.
- Import the asset in the component (Vite `import url from 'β¦'`); never hardcode `/public` paths.
- After adding, insert a row into the `media` table via `npm run media:register -- <path>`.β¦or, for simple projects, just a CLAUDE.md stub:
## Image assets
Generated images go in `public/img/`, kebab-cased; reference them with a root-relative `/img/β¦` URL.Chat history (read-only). History is provider-scoped β the routes read whichever backend's
transcript store matches the request's ?provider= (default anthropic), so the π view always
shows the conversations from the provider currently selected on the options page:
anthropicβclaudewrites one.jsonltranscript per session under~/.claude/projects/<encoded-cwd>/(the cwd with every non-alphanumeric char turned into a single-; matched case-insensitively against the directory listing).openaiβcodexwrites one rollout transcript per session under<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonlin one global tree (not partitioned by repo), so the shim reads each rollout'ssession_metaline and keeps only those whosecwdis this repo. The replayed events come from the rollout'sevent_msg/response_itemrecords β a different on-disk shape thancodex exec --json's live stream, but mapped onto the same Β§6 shapesstreamCodexemits (tool/file_edit, notool_result,result= the last agent message). Two rollout shapes: codex β₯ 0.148 replaced the legacy per-event records (event_msguser_message/agent_message/patch_apply_end,response_itemfunction_call) with oneevent_msg/item_completedper item (item.typeUserMessage/AgentMessage/Reasoning/FileChangeβ a path-keyedchangesobject β /CommandExecution), andtask_completecarries the authoritativelast_agent_message. No single rollout mixes the two, so both mappers stay: a shim that reads only the legacy records shows every recent session as(untitled)with0 msgand replays an empty transcript. The PREAMBLE the shim prepends to every codex prompt is stripped from titles and the replayeduserevent so they show the actual request (parity with claude, whose PREAMBLE rides insystemPrompt).grokβ Grok writes sessions under~/.grok/sessions/<encodeURIComponent(realpath(cwd))>/<uuid>/(summary.json+chat_history.jsonl). Livestreaming-messages-jsonand on-disk history use different shapes (history uses stringcontent+ JSON-stringtool_calls.arguments); the shim uses a separate history mapper. List projection readssummary.json(generated_title,created_at/updated_at,head_branch,num_chat_messages, β¦). PREAMBLE is stripped from titles and replayeduserevents when present.
Two GET routes, behind the same Bearer+CORS gate as /meta, expose the current repo's sessions:
GET /history[?provider=β¦]β{ sessions: [{ id, title, firstPrompt, startedAt, endedAt, branch, messageCount }] }, newest first.titleis the session'sai-titleif present (claude), Grokgenerated_title/session_summary, else its first user prompt. Missing store / no sessions for this repo β{ sessions: [] }.GET /history/<id>[?provider=β¦]β{ id, events: [...] }, whereeventsreuses the Β§6 shapes above (plus theuserevent) so the panel replays a past session through the same renderer.idmust be a valid session UUID (regex-validated; the claude path is path-traversal-guarded, the codex path re-checks the rollout'scwdagainst the repo, the grok path scopes torealpathof this repo); a bad/missing/foreign id β 404. Lifecycle events (start/commit/done) are not emitted for a replay.
Auto-commit opt-out (additive). /design and /generate-image accept an optional top-level
autoCommit (boolean). When it is exactly false, the shim skips the per-run commit β the
run's edits stay uncommitted in the working tree and no commit event is emitted. (Auto-reload-on-save
still fires regardless, since it keys off file_edit, not commit.) Absent or any other value keeps the default
auto-commit, so old clients are unaffected. The extension exposes this as a per-origin
auto-commit checkbox (on by default) in Options and sends the resolved value with every run.
Resume (additive). /design accepts an optional top-level resume (a session UUID). When
present and valid, the shim passes resume to the SDK query so the run continues that
conversation. The busy lock and the per-run auto-commit (diff of git status before/after) are
unaffected β only files changed by this run are committed. An absent/invalid value starts fresh.
The extension's π history view offers a β» Resume action that threads subsequent sends into the
chosen session.
Multiple element targets (additive). /design and /generate-image accept a top-level
elements array of Β§7 captures β the composer stacks up to
5 picks per message (capped on both sides so the prompt/context window stays sane; the shim
re-caps with slice(0, 5)). The legacy single top-level element is still accepted (normalized to
a one-entry array), so old clients are unaffected.
When the user clicks an element in Markup mode, its capture is added to the composer as a removable
chip. Picks stack β the picker stays armed, so each consecutive click appends another target, up
to 5 per message (the cap auto-disarms the picker and keeps the prompt/context window sane; the
shim re-caps server-side). Picking ends via Esc, clicking π― again, or the cap. On send the extension POSTs
them as a top-level elements: [ β¦ ] array (plus a top-level screen = current route/view, i.e.
location.pathname + location.search + location.hash, so hash-based routes are captured too); the
shim also still accepts the legacy single top-level element. Each entry:
Centralized, semantic class names usually pinpoint the source; for CSS-in-JS / hashed classes, lean
on text + domPath + screen, or add framework-fiber data (Β§8.4).
matchedStyles is the strongest "which file/rule do I edit?" signal, and the one field only the
Β§8.5 chrome.debugger picker can produce β a content
script has computed styles but not the matching rule with its stylesheet origin. While attached the
background worker calls CSS.getMatchedStylesForNode, keeps only authored (origin: "regular") rules
plus any inline style, orders them winning-rule-first, and maps each rule's styleSheetId to its
sheet header's sourceURL (+ the header's startLine, so inline <style> lines are correct). It's
capped (β€12 rules, β€24 props each) to keep the prompt sane, and best-effort β same-process iframes
resolve, an OOPIF that won't simply omits the field. source is a dev-server URL (Vite/webpack add
the origin and a ?t= cache-buster); the shim's prompt tells claude to strip those to a repo path,
and sourceMapURL is carried but not yet resolved (a follow-up could walk it shim-side to reach
the original .scss / styled-component). The content-script picker omits matchedStyles entirely, so
everything downstream treats it as optional.
screenshotDataUrl is captured on every pick: Chrome has no "screenshot this element" API, so the
background worker grabs the visible tab (chrome.tabs.captureVisibleTab) and the content script crops
it to rect (scaling by devicePixelRatio, clamping to the viewport, downscaling to a modest max
edge). Capture is best-effort β restricted pages, a zero-size rect, or a load failure just yield no
screenshot and the flow degrades to text-only. The composer shows it as a removable attachment
chip under that element's identity chip (thumbnail + dimensions + β); removing it drops the pixels
so they're never sent. On /design
the screenshots ARE sent (with the UI-only screenshotW/H stripped) β the shim writes each to a temp
file and asks claude to Read it (Β§5). On /generate-image they're stripped
(that route uses imageDataUrl instead).
imageDataUrl is captured on every pick where the target is an <img> whose pixels the picker could
read (same-origin / CORS-enabled canvas; tainted images are silently skipped). The composer keeps it
only when Image Generation is toggled on (the "+" menu in the send area) β then it drives
image-to-image in /generate-image; for plain /design sends the composer strips it back out, so it
never bloats a non-image request. Image Generation is a per-send toggle, not a separate picker: pick
any element with π―, flip the toggle, and the shim places the generated image as the <img>'s src
or the element's CSS background-image depending on the element type.
Pasted images. With the composer focused, Ctrl/Cmd+V of a copied image (e.g. a screenshot)
creates a synthetic capture β no DOM fields, pasted: true, with both screenshotDataUrl and
imageDataUrl set to the same PNG downscaled to a max edge of 1024. It stacks as a removable
π pasted image Β· WΓH chip against the same 5-target cap (text paste falls through untouched). On
/design the kept screenshotDataUrl is written to a temp file for claude to Read; on
/generate-image the kept imageDataUrl is used as the image-to-image source. Keyed off pasted,
the shim swaps the "screenshot of the selected element" wording for neutral "[The user pasted this
imageβ¦]" wording so claude decides from the request whether it's a visual reference or an asset to
place.
Manifest V3. The chat UI lives in the browser's side panel (sidepanel.html, an extension page)
β so it docks beside the page (which reflows) instead of overlapping it, and it persists while
open, surviving a whole run regardless of the MV3 service-worker idle-kill. The element picker
stays a content script in the page (content/inject.js) β only a content script can hit-test
the app's DOM and overlay a highlight. The two halves coordinate over runtime messaging (Β§8.4).
{
"manifest_version": 3,
"name": "Slide Write",
"version": "0.2.x",
"permissions": ["storage", "scripting", "sidePanel", "tabs", "debugger"],
"host_permissions": ["<all_urls>"],
"optional_host_permissions": ["https://*/*", "http://*/*"],
"background": { "service_worker": "background.js" },
"action": { "default_title": "Slide Write β open panel", "default_icon": { /* β¦ */ } },
"side_panel": { "default_path": "sidepanel.html" },
"content_scripts": [{
"matches": ["http://localhost/*", "http://127.0.0.1/*", "https://localhost/*"],
"js": ["content/inject.js"],
"run_at": "document_idle"
}],
"web_accessible_resources": [{
"resources": ["content/picker.js"],
"matches": ["https://*/*", "http://*/*"]
}]
}host_permissions: ["<all_urls>"] is load-bearing, not laziness: the element-screenshot crop
calls chrome.tabs.captureVisibleTab, which Chrome only grants with <all_urls> or a per-tab
activeTab gesture β a narrow host match (http://localhost/*) never satisfies it. The old in-page
panel happened to get activeTab from the toolbar click; opening a side panel doesn't, so the broad
host permission is what keeps screenshots working across tab switches and reloads.
The action has no default_popup β clicking the toolbar icon opens the side panel.
background.js calls chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) (the
setting persists, so it's set explicitly on onStartup/onInstalled); the toggle-panel command
opens it via chrome.sidePanel.open({ windowId }).
The content-script content_scripts entry (the picker) still matches localhost only; the side panel
itself works on every tab. Non-localhost origins (the Β§13 reverse-proxy fallback) get a
runtime-granted picker content script: optional_host_permissions lets the options page call
chrome.permissions.request({ origins }) on the save click (user gesture), and on grant the
background registers content/inject.js for that origin via
chrome.scripting.registerContentScripts (id sw:<origin>). Disabling unregisters; deleting also
removes the permission; onStartup/onInstalled reconcile the registry against config + granted
permissions (cleared on extension reload/update). web_accessible_resources exposes only
content/picker.js β the one module the page-context content script imports; the chat modules
(panel.js/sse.js) load as same-origin resources of the side-panel page and need no exposure.
<all_urls> host permission it isn't subject to CORS β see Β§10.)
EventSource can't POST or set headers, so read the fetch body manually:
export async function streamDesign(shimUrl, token, payload, onEvent, signal, path = "/design") {
const res = await fetch(`${shimUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "text/event-stream",
"Authorization": `Bearer ${token}` },
body: JSON.stringify(payload), signal,
});
if (!res.ok || !res.body) throw new Error(`request failed: ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let sep;
while ((sep = buf.indexOf("\n\n")) !== -1) {
const frame = buf.slice(0, sep); buf = buf.slice(sep + 2);
const data = frame.split("\n").filter(l => l.startsWith("data:"))
.map(l => l.slice(5).replace(/^ /, "")).join("\n");
if (data) { try { onEvent(JSON.parse(data)); } catch {} }
}
}
}shimUrl is the per-origin value from config, e.g. http://localhost:4040.
- Listen on
windowin the capture phase formousemove/click. Capture-phasepreventDefault()+stopPropagation()on click means marking an element never triggers the app's own handlers. Suppression is per-target: clicks/presses on the picker's own overlay (the highlight box + label, taggeddata-slidewrite-ui) and on barebody/htmlpass through untouched. (The chat panel is no longer in the page β it's the side panel β so the picker's only own-UI in the page is its highlight overlay.) document.elementFromPoint, then walk up and skip your own UI β tag every node you render withdata-slidewrite-uiand ignore hits inside one:function skipOwnUI(el) { let n = el; while (n && n !== document.body && n !== document.documentElement) { if (n.dataset && "slidewriteUi" in n.dataset) return null; n = n.parentElement; } return (!el || el === document.body || el === document.documentElement) ? null : el; }
- Highlight box is
position:fixed; pointer-events:noneat the target'sgetBoundingClientRect(). - Capture the Β§7 context per click and STAY ARMED for
consecutive picks β each capture is handed to
content/inject.js, which crops the screenshot and posts it to the side panel (which stacks it as a chip), and picking continues. While the consumer handles a pick (screenshot capture), the highlight hides and further clicks are swallowed, then picking re-arms. The picker disarms on Escape (onPick(null)after cleanup) or via thestop()functionstartPickerreturns β the bridge calls it when the side panel sendssw-disarm-picker(π― toggled off, or the 5-element cap reached). BuilddomPathas annth-of-typechain of β€5 ancestors, stopping at the firstid. - Shift+click copies the full target path to the clipboard in addition to the normal pick β
the element is still captured into the chat context (parity with the Β§8.5
CDP picker). The copy is gated on two things: the global
copyPathsetting (an options-page checkbox, default ON; threaded through to the picker via thesw-arm-picker/sw-picker-startmessages) and the Shift modifier on the click. A plain click just picks for the chat. UnlikedomPath, this path is uncapped and never stops at anid: it walks all the way up to<body>, keeping every class and annth-of-typedisambiguator so the result resolves uniquely viadocument.querySelector. Usesnavigator.clipboard.writeText(inside the click gesture) with anexecCommand("copy")fallback, and shows a brief "β Path copied" confirmation.
The UI is split across two contexts that talk over runtime messaging:
sidepanel.html/sidepanel.jsβ the side-panel host. On load (and on every active-tab change βtabs.onActivated/onUpdated,windows.onFocusChanged) it resolves the active tab's origin, looks up that origin's config via the background store, fetchesGET <shimUrl>/metaonce, and mounts the panel. While loaded, it polls only/healthfor liveness. The single side-panel document follows the active tab, caching one panel instance per browser tab: switching tabs swaps the visible instance, while each tab retains its transcript, draft, picks, history view, and resumed thread. Only the visible instance polls for liveness. A same-tab navigation to a new origin starts a fresh instance; a same-origin navigation just updates thescreenvalue. It owns the picker bridge: the π― button sendssw-arm-picker/sw-disarm-pickerto the active tab's content script, and it listens forsw-element-picked(β stack a chip) andsw-picker-state(β reflect the π― toggle).content/inject.jsβ the page-side picker bridge (all that's left in the page). Inert until the side panel arms it; then it importscontent/picker.js, and for each pick crops the element screenshot βcaptureElementShotasksbackground.jsforchrome.tabs.captureVisibleTab(only the background can call it) and crops to the element's rect using the page'sdevicePixelRatio/ viewport (which is why this must run in the page, not the panel) β then posts the Β§7 context back withchrome.runtime.sendMessage({ type: "sw-element-picked", β¦ }).content/panel.jsβ transcript + composer, now mounted into the side-panel document. Renders each Β§6 event as a row; coalesce consecutive same-role streaming deltas into one bubble; tool/result rows break the chain. Footer textarea (Enter sends, β/Ctrl+Enter or Shift+Enter inserts a newline);AbortControllercancels on close. The composer's toolbar row holds adjacent model and reasoning- effort selectors (populated from/meta, persisted per-origin) and the send button. A π header button opens a history view:GET /history?provider=<selected>lists this repo's past sessions for the chosen provider; picking one callsGET /history/<id>?provider=<selected>and replays it read-only through the sameonEventrenderer. A β» Resume action threads subsequent sends into that session via the/designresumefield. Page-bound actions are injected as callbacks bysidepanel.js:screencomes from the active tab's URL, auto-reload-on- save callschrome.tabs.reload, and β closes the side panel (window.close).content/sse.jsβ the SSE reader plusfetchHistory/fetchHistoryDetailJSON GET helpers (both pass the per-origin provider as?provider=so history matches the selected backend).background.jsβ ownschrome.storageconfig; serves get/set to the side panel & options; performscaptureVisibleTabon request; drives the opt-inchrome.debuggerpicker (Β§8.5); opens the side panel from the toolbar action / command.options.html/jsβ per-origin rows:{ origin, enabled, token, shimUrl }.styles.cssβ the side-panel document's styles (the panel fills the panel viewport).
Roadmap: the picker content script runs in the page, so it can read the React fiber
(__reactFiber$β¦) on the clicked node to recover the component name + _debugSource (file:line) in
dev builds and post it alongside the DOM context β far more precise than class/domPath grepping for
CSS-in-JS / hashed-class projects. Optional, framework-specific.
A per-origin alternative to the Β§8.3
content-script picker, off by default. Enable it with the "debugger picker" checkbox on the
options page (per origin, stored as origins[<origin>].debuggerPicker). When on, the side panel
routes the π― button through background.js instead of the page: it sends sw-picker-start /
sw-picker-stop to the background worker, which drives the Chrome DevTools Protocol via
chrome.debugger β Overlay.setInspectMode for the highlight, DOM.resolveNode +
Runtime.callFunctionOn to run swCapture (content/capture.js) in the picked node's own frame,
Page.captureScreenshot (clip) for the thumbnail, and CSS.getMatchedStylesForNode (via DOM.requestNode,
with sheet headers tracked from CSS.styleSheetAdded) for the Β§7
matchedStyles. background.js is therefore an ES module
("background.type": "module") so it can import capture.js.
Why offer it:
- Reaches cross-origin iframes. The native inspector overlay is browser-drawn and descends into every frame, so you can pick elements the in-page content script can't see.
- Device-accurate screenshots.
Page.captureScreenshotclips to the node's box model β nodevicePixelRatio/ viewport crop math, and it captures beyond the viewport. - Source-located CSS (
matchedStyles). Only this backend can handclaudethe authored rules that actually apply, each with its source file + line β the strongest pointer to which file/rule to edit. A content script has computed styles but not the matching rule's stylesheet origin. See Β§7. - Copies the CSS selector on Shift+click β the uncapped
fullPath, same as the Β§8.3 content-script picker, gated on the globalcopyPathsetting. The inspect event (Overlay.inspectNodeRequested) carries no modifier state, so the picker mirrors the page's live Shift state into awindow.__swShiftglobal (set by a capture-phase keydown/keyup listener installed alongside the Escape hook) and reads it at pick time.
Costs / behavior:
- Needs the
debuggerpermission, declared required in the manifest (Chrome forbidsdebuggeras an optional permission, so it can't be requested on demand). It's therefore always granted, and the extension shows the broader install-time permission warning β adding it prompts a one-time permission re-acceptance on reload, even for users who never turn the mode on. Chrome also shows a "Slide Write started debugging this browser" banner while picking; finishing (π―/Esc/cap) detaches and clears it. - One debugger slot per tab: if DevTools (or another debugger) is attached,
attachfails. The background reportssw-picker-error, the panel surfaces it (panel.notify, red row), and it stays in debugger mode β close DevTools and click π― again (no auto-fallback to the content-script picker). - No content script / no Β§8.1 dynamic registration is needed in this mode β the worker reaches the page directly.
Both backends post the same sw-picker-state / sw-element-picked messages and produce the identical
Β§7 element contract, so the shim, the Β§6
SSE stream, and the composer are unaware of which picker was used (the Python shim is unaffected β the
picker is extension-only).
Per origin, the options page stores { enabled, token, shimUrl } β e.g.
http://localhost:5173 β http://localhost:4040. The shim and app run on different ports
(cross-origin), so shimUrl is explicit (not derivable). The side panel looks up the active
tab's origin; if enabled, it calls GET <shimUrl>/meta and shows "wired to <project> @
<branch>" so you can confirm the tab points at the repo you expect, and re-resolves whenever you
switch tabs. Run several projects at once β each shim on its own port, each origin mapped
accordingly.
The shim runs arbitrary code edits + shell in a repo as you. Defenses:
- Loopback bind. The shim listens on
127.0.0.1by default β never a public interface. It's reachable from the browser solely through VS Code's port forward (authenticated by the VS Code remote connection) or directly on the same machine. The opt-in--bind <addr>/SLIDEWRITE_BINDoverride exists solely for the Β§13 reverse-proxy fallback (bind the docker bridge gateway so a containerized proxy can reach the host shim); don't use it otherwise. - Bearer token. Every route except
/healthrequiresAuthorization: Bearer <SLIDEWRITE_TOKEN>; reject with 401 first. Use a random secret per project; never commit it. - CORS allowlist = anti-CSRF. The shim's
Access-Control-Allow-Originonly names your app's origin, so a random site you browse can't read responses from the shim even though it's on localhost. (The chat itself now fetches from the extension's side-panel page, which β as an extension page with the<all_urls>host permission β isn't subject to CORS at all; that's expected, since the side panel is trusted extension UI, not a web origin. CORS still blocks arbitrary web pages, and the Bearer token gates everyone.) Two independent gates against web-origin attackers: token + CORS. - Extension host scope. The extension requests
<all_urls>(required forcaptureVisibleTab-based element screenshots, Β§8.1). It's broad read/screenshot access on the browser side, appropriate for a single-developer tool; the per-origin enable + token still gate where it actually does anything. The shim's own defenses below are unchanged by it. - Runs as you (non-root). Edits are host-owned;
bypassPermissionsis allowed without root hacks. Optional hardening: acanUseTooldeny-list (WebFetch/WebSearch,git push) to blunt prompt-injection exfiltration. - Prompt injection. A malicious string in the repo could steer
claude; the working-directory boundary (cwd: REPO) is the main mitigation. For higher assurance, run against a throwawaygit worktree. - History is read-only and repo-scoped.
/history*only read the selected provider's transcript store for this repo β claude's~/.claude/projects/<this repo>/(anthropic), codex's rollout tree filtered tosession_meta.cwd === <this repo>(openai), or Grok's~/.grok/sessions/<encodeURIComponent(realpath(repo))>/(grok). The sessionidis UUID-validated before any file read; the claude path is additionally path-traversal-guarded, and the codex path re-checkscwdagainst the repo before replaying so it can't be coerced into reading another project's session. Same Bearer+CORS gate as/meta.
Single-developer, trusted-local only. Not multi-tenant or public.
Two interchangeable shims β pick Node (Β§11.1, the Agent SDK) or
Python (Β§11.2, drives the claude CLI directly; for hosts without
Node). Both expose the identical HTTP+SSE surface, so the extension setup (Β§11.3)
is the same either way.
Prerequisites: Claude Code installed and logged in (claude on PATH), Node 18+, VS Code.
Per machine (once):
cd shim && npm install # pulls @anthropic-ai/claude-agent-sdkPer project: run the shim pointed at the repo, on its own port:
node shim/slide-write.mjs --repo /path/to/project --port 4040 \
--origin http://localhost:5173 --token "$(openssl rand -hex 16)"For servers without Node. The shim is stdlib-only Python 3.10+, so there's no install step β but
it drives the claude CLI, which must be on PATH.
Prerequisites: Claude Code installed and logged in (claude on PATH β the native installer
curl -fsSL https://claude.ai/install.sh | bash ships a self-contained binary that needs no Node),
Python 3.10+, VS Code.
Per project: run the shim pointed at the repo, on its own port:
python3 shim/slide-write.py --repo /path/to/project --port 4040 \
--origin http://localhost:5173 --token "$(openssl rand -hex 16)"If claude isn't on PATH, point at it with --claude-bin /path/to/claude (or
SLIDEWRITE_CLAUDE_BIN). Skills are off by default to match the Node shim; add --use-skills to
enable them.
Add --debug (or set SW_DEBUG=1) to log every message (type/subtype) to stderr during a
run β useful for seeing the raw message sequence before it's translated into SSE events.
In remote/WSL/container dev, run the shim in a VS Code terminal on the code host β VS Code
auto-forwards port 4040 (and your dev server's port) to your laptop's localhost. Open the app via
its forwarded localhost URL.
In the extension (once per project): open options β add origin http://localhost:5173, set
shimUrl http://localhost:4040 + the token, enable. Choose Provider per origin (Anthropic /
OpenAI / xAI (Grok)). Then open the app, click the toolbar button, and design. To stop: kill the
shim process.
Optional β Grok (xAI) provider: install the Grok Build CLI
(curl -fsSL https://x.ai/cli/install.sh | bash β re-verify the install URL at write time if it
moves), then grok login (or export XAI_API_KEY on the shim process). Select xAI (Grok) in
Options. Image generation still uses Gemini; only the design/placement agent step switches to Grok.
Each phase is independently testable; build and verify in order.
- Shim. Implement
shim/package.json+shim/slide-write.mjs(Β§5). Then:Expectnode shim/slide-write.mjs --repo "$PWD" --port 4040 --token test --origin http://localhost:5173 & curl -s localhost:4040/health curl -s -H 'Authorization: Bearer test' localhost:4040/meta curl -sN -X POST localhost:4040/design -H 'Authorization: Bearer test' \ -H 'Content-Type: application/json' -d '{"prompt":"append a CSS comment to <some file>"}'
start β file_edit β result β commit β done, then one scoped commit (git reset --hard HEAD~1to clean up). Confirm SDK message shapes here. - Extension β minimal.
manifest.json(side_panel,<all_urls>),background.js,options.*,sidepanel.html+sidepanel.js+content/panel.js+content/sse.js(chat only, no picker). Click the toolbar icon β the side panel opens, resolves the active tab's origin, and drives a text-prompt change end to end; render the Β§6 events. - Extension β picker.
content/picker.js+ thecontent/inject.jsbridge (Β§8.3/Β§8.4); side panel arms the picker, receives the Β§7 contract + cropped screenshot over messaging; anchored composer; markup toggle. - Polish. Auto-reload-on-save option, token UX, the Β§10 checklist.
- History & resume. Add the
/history+/history/<id>routes and theresumefield (Β§6) to the shim, then the π history view + β» Resume in the panel. Verify:Expect the resumed run to reference prior context and still commit only its own changes. In the UI: π β pick a session β read-only replay β β» Resume β follow-up threads into that session.curl -s -H 'Authorization: Bearer test' localhost:4040/history # {sessions:[β¦]} newest-first curl -s -H 'Authorization: Bearer test' localhost:4040/history/<uuid> # {id,events:[β¦]} (404 on bad id) curl -sN -X POST localhost:4040/design -H 'Authorization: Bearer test' \ -H 'Content-Type: application/json' -d '{"prompt":"what did you just change?","resume":"<uuid>"}'
- (Optional) fiber-based element resolution (Β§8.4).
- (Optional) Grok provider. With
grokon PATH and logged in:Expectcurl -s -H 'Authorization: Bearer test' localhost:4040/meta | jq '.providers[] | select(.id=="grok")' curl -sN -X POST localhost:4040/design -H 'Authorization: Bearer test' \ -H 'Content-Type: application/json' \ -d '{"prompt":"append a CSS comment to <some file>","provider":"grok","model":"grok-4.5","effort":"low"}' curl -s -H 'Authorization: Bearer test' 'localhost:4040/history?provider=grok'
start β file_edit (search_replace|write) β result β commit? β done, then a session inGET /history?provider=grok. Clean up withgit reset --hard HEAD~1if auto-commit landed.
Use this only if you must open the app at a public hostname (e.g. https://app.example.com)
instead of a VS Code-forwarded localhost URL. A public-origin page calling localhost hits
cross-origin + Chrome's Private/Local Network Access checks, so instead mount the shim on the app's
own hostname under a path prefix, making the call same-origin.
With Traefik (Docker-label or file-provider form), route PathPrefix(/_slidewrite) β StripPrefix β
the shim's port, at higher priority than the apps' catch-all Host(...) routers. For a single app,
scope the rule with Host(app) && β¦; with the shim's multi-host mode (Β§5.2 --repo-root /
--repos) one Host-less router covers every app the proxy serves β the shim resolves the repo from
the forwarded Host header and 404s hosts that don't map. The extension then uses
shimUrl = location.origin + "/_slidewrite" (its default). Bind the shim to the proxy network
instead of pure loopback β run it with --bind <docker bridge gateway> (e.g. --bind 172.18.0.1)
and point the Traefik service at http://<gateway>:<port>; the default stays 127.0.0.1 so this is
per-invocation opt-in. Keep the token + an ipAllowList middleware as the boundary (caveat: docker
NAT on the published port can rewrite the source to the gateway IP, so include the bridge subnet
and treat the bearer token as the real gate). This is strictly more setup than the default; prefer
VS Code forwarding whenever you can.
The resulting topology β page, proxy, and shim all share the app.example.com origin, so the
extension's call to /_slidewrite/design is same-origin (no CORS, no Private/Local Network
Access prompt). Contrast with the default Β§3 diagram, where the browser reaches a
loopback shim over a VS Code-forwarded localhost port:
%%{init: {'theme':'neutral', 'flowchart': {'diagramPadding': 16, 'nodeSpacing': 55, 'rankSpacing': 75, 'padding': 14, 'subGraphTitleMargin': {'top': 6, 'bottom': 14}}}}%%
flowchart TB
subgraph BROWSER["Browser β any machine, public internet"]
direction TB
PAGE["app page Β· https://app.example.com"]
EXT["browser extension<br/>shimUrl = location.origin + /_slidewrite (same-origin)"]
PAGE -.- EXT
end
subgraph PROXY["Reverse proxy Β· Traefik β terminates TLS for app.example.com"]
direction TB
R1["router: PathPrefix(/_slidewrite) Β· higher priority<br/>StripPrefix β shim Β· + ipAllowList middleware"]
R2["router: Host(app.example.com) Β· catch-all<br/>β app dev server"]
end
subgraph CODE["Code host Β· Docker network (remote server / WSL / container)"]
direction TB
SHIM["slide-write shim Β· 172.18.0.1:4040<br/>--bind docker bridge gateway Β· HTTP + SSE"]
CLAUDE["claude (headless, Agent SDK)<br/>reuses ~/.claude Β· runs as you (non-root)"]
DEV["app dev server Β· Vite, β¦ (unchanged)"]
REPO[("repo")]
SHIM --> CLAUDE
CLAUDE -- "edits files" --> REPO
REPO -- "file watch β HMR" --> DEV
end
EXT == "fetch + SSE Β· POST /_slidewrite/design<br/>same-origin + Bearer token" ==> R1
R1 == "StripPrefix β http://172.18.0.1:4040/design" ==> SHIM
DEV == "serves app page + HMR (wss)" ==> R2
R2 == "https://app.example.com" ==> PAGE
classDef hot fill:#bdbdbd,stroke:#404040,color:#111111;
classDef repo fill:#9e9e9e,stroke:#212121,color:#111111;
class SHIM,CLAUDE hot;
class REPO repo;
The visual-edit β source space is active β worth a scan before building:
- Locator.js β click a rendered element, jump to its component source (validates Β§8.4).
- Onlook, Builder.io Visual Copilot β visual editing that emits code changes.
The distinctive angle here: a generic local shim driving your already-installed claude against
the real repo, surfaced in any app via a browser extension, reaching every dev topology through VS
Code's port forwarding β no Docker, no proxy, no API key, zero frontend footprint.
{ "tag": "button", "id": null, "className": "btn btn-primary", // full class string "text": "New", // textContent, trimmed, β€120 chars "domPath": "div.topbar > button.btn.btn-primary", // nth-of-type chain, β€5 ancestors, stops at first id "rect": { "x": 1180, "y": 16, "w": 64, "h": 32 }, "matchedStyles": [ // optional; CDP picker only (Β§8.5) β authored CSS that applies { "selector": ".btn-primary", // winning rule first; user-agent/injected rules dropped "source": "/src/components/Button.module.css", // dev-server URL; strip origin/query β repo path "line": 42, // 1-based, within `source` "sourceMapURL": "Button.module.css.map", // optional; present for SCSS / extracted CSS-in-JS (unresolved) "props": { "background-color": "#2563eb", "padding": "8px 16px" } } ], "imageDataUrl": "data:image/png;base64,β¦", // optional; present only when the target is an <img> "screenshotDataUrl": "data:image/png;base64,β¦", // optional; a screenshot of the picked element "screenshotW": 64, "screenshotH": 32, // UI-only (chip label); not forwarded to the shim "pasted": true // optional; set only for clipboard-pasted images (no DOM fields) }