{"slug": "gloss-annotate-live-code-and-provide-feedback-to-an-agent", "title": "Gloss – Annotate live code and provide feedback to an agent", "summary": "Ryan Brunner released Gloss, an open-source tool that opens a web app in a Chromium window with a feedback bar so reviewers can annotate live pages and submit comments to Claude for automated fixes. Gloss installs via Homebrew or npm (Node 22.12 or later), requires a one-time ~150 MB Chromium download, and drives a loop of `gloss wait`, `gloss working`, and `gloss ready` commands until the reviewer presses Approve. The tool acts only as a channel between reviewer and agent, never fixing anything itself, and stores session state in ~/.gloss/sessions/<id>.json outside the repo.", "body_md": "Review a running web app in a browser with a feedback bar across the top, and hand what you wrote to Claude.\n\n`gloss open <url>` opens the page in a Chromium window with the Gloss bar over\nit. The reviewer writes comments in the bar and presses **Submit** to send\nthem to Claude as a round. Claude applies them and the window reloads with its\nsummary of what changed. This repeats until the reviewer presses **Approve**.\n\nGloss never fixes anything itself. It is the channel between the reviewer and\nwhatever runs Claude (Claude Code, or Reeve). The agent drives the session\nwith four short commands: `gloss wait` for the reviewer's verdict, `gloss working` and `gloss ready` either side of its changes, and `gloss close`. The\nClaude Code plugin in this repo runs that loop for you.\n\nWith Homebrew:\n\n```\nbrew install ryanbrunner/tap/gloss\ngloss install-chromium            # once: about 150 MB\n```\n\n`gloss install-chromium` downloads the Chromium that Gloss's own Playwright\ndrives, so the browser always matches it.\n\nNode 22.12 or later.\n\n```\nnpm install\nnode bin/gloss.js install-chromium   # once: about 150 MB\nnpm link                             # optional: puts `gloss` on your PATH\n```\n\nWithout `npm link`, run it as `node bin/gloss.js …` or `npm run gloss -- …`.\n\nThe repo is a Claude Code plugin marketplace. With `gloss` on your PATH:\n\n```\n/plugin marketplace add ryanbrunner/gloss\n/plugin install gloss@gloss\n```\n\nFrom a checkout, `/plugin marketplace add /path/to/gloss` works too.\n\nThen `/gloss http://localhost:3000` opens the page and loops: it waits for\nyour round, marks itself working, applies the comments, says it is ready\nwith a summary, and waits again, until you approve.\n\n```\ngloss open <url> [--name N]    show <url> in the Gloss window, starting a session if there is none\ngloss status [--name N] [--json]   exit 0 and say where the session is and its phase, or exit 1 if there is none\ngloss close [--name N]         end the session and close its window\ngloss install-chromium         download the Chromium Gloss drives, once\ngloss --version                print Gloss's version\n\ngloss wait [--name N]          block until the reviewer submits or approves; print the verdict as JSON\ngloss working [message]        the bar says Claude is working, with the message; the reviewer cannot submit\ngloss ready [summary]          reload the window, show the summary, and hand the next round to the reviewer\n```\n\n`gloss wait` prints one JSON document on stdout and nothing else, described\nin [docs/verdict.md](https://github.com/ryanbrunner/gloss/blob/main/docs/verdict.md). **Only exit 0 with `\"approved\": true`\nis approval.** Exit 1 with nothing on stdout means no verdict: no session, or\nthe window was closed or the session ended while it waited. Asked again\nbefore `working` or `ready`, it prints the same round, so after a round the\nnext command is `gloss working`, not `gloss wait`.\n\nA round goes: the reviewer submits (**submitted**), the agent runs `gloss working` (** working**), then `gloss ready` (back to **reviewing**, one round\non). Approve ends it (**approved**).\n\n`gloss open` returns as soon as the window is up (within 15 seconds) and\nprints the session's address. Run it again from the same directory and it\nmoves the same window to the new URL. Closing the window ends the session too.\n\nThere is one session per working directory. `--name` gives a directory more\nthan one.\n\n- `~/.gloss/sessions/<id>.json` : the running session's pid, port and token.\nThe id is a hash of the directory and`--name` . It is outside the repo, so\na session never dirties a worktree.\n- `~/.gloss/logs/<id>.log` : the session process's output.\n\nSet `GLOSS_HOME` to move both.\n\nThe session serves a small HTTP API on `127.0.0.1` only. Every request needs\nthe token from the state file:\n\n```\nstate=~/.gloss/sessions/<id>.json\ncurl -H \"Authorization: Bearer $(jq -r .token $state)\" \\\n  \"http://127.0.0.1:$(jq -r .port $state)/api/state\"\n```\n\n| Route |  | \n|---|---|\n| `GET /api/health` | `{ok, pid, url}` : the page the window is on now | \n| `GET /api/state` | `{round, phase, message, summary, comments: [{id, body, createdAt, page, sentIn, pin?}]}` | \n| `GET /api/verdict?wait=N` | the verdict, or `{pending: true}` after N seconds (at most 30); 409 while working, 410 once the session is ending | \n| `POST /api/working` | `{message}` : the agent has the round | \n| `POST /api/ready` | `{summary}` : the agent is done; reloads every page | \n| `POST /api/navigate` | `{url}` : move the window | \n| `POST /api/close` | end the session | \n\n`round` is the round being written now. `phase` is `reviewing`, `submitted`,\n`working` or `approved`. A comment's `sentIn` is the round it went out in, or\n`null` while it is unsent. A pinned comment's `pin` has the element's `selector`,\n`tag`, `text`, `box` and the `viewport`, and the path of its `screenshot`.\n\n- Type a comment and press Enter or Add. Shift+Enter adds a new line.\n- **Interact** and**Select** , beside the round, are the tools. In Interact\nthe page works as usual. In Select, the page's links and buttons do nothing:\nthe element under the pointer is outlined, with its tag and size, and a\nclick opens a comment box beside it, headed with the element's tag and text.\nEnter or**Add** pins the comment to the element; the session keeps a\nselector for it and a screenshot of it, taken with the outline and markers\nhidden. Select stays on after Add or**Cancel** , for the next element, until\nyou press Escape (which closes an open box first) or Interact. On a phone\nthe tools are one crosshair button that turns Select on and off. They are\ndisabled once the review is approved.\n- Each pinned comment gets a numbered marker on its element's top right corner, which follows it as the page scrolls; hover it to see the comment and outline the element. The list shows the same number with the element's tag and text. A marker for an unsent comment stays where the element was if its selector stops finding it. Once a round is sent, its markers are dimmed, and shown only where the selector still finds a visible element.\n- **Comments (n)** counts the comments not yet sent. The list shows those\nfirst, each with a delete button, then every earlier round under \"Sent in\nround N\", dimmed and read-only, so you can check what was asked.\n- **Submit** sends the unsent comments as a round. It is disabled when there\nis nothing new, and while the round is with Claude.\n- **Approve** ends the review. With unsent comments, it asks first, in the\nbar: \"Approve and discard N unsent comments?\"**Discard & approve** deletes\nthem for good (they never reach Claude, and there is no undo);**Cancel** leaves everything as it was.\n- The status beside the buttons says where the round is: \"Sent round N, waiting for Claude\", \"Claude is working: …\", Claude's summary once it is ready, or \"Approved\". On a phone it sits in a line under the bar.\n- Comments belong to the session, not the page, so they survive a reload and every tab shows the same list.\n- The bar lives in a shadow root on one `<gloss-bar>` element on`<html>` . Page\nCSS cannot reach into it and its CSS cannot leak out. The changes to the\npage's own styles are`html { margin-top: 44px }` , which pushes the page down\nbelow the bar, and 44px added to the page's own`scroll-padding-top` , so an\nanchor jump lands below the bar and any sticky header the page allows for.\nFixed and sticky elements placed from the top of the viewport, such as a\nheader, are moved down by the same 44px.\n\n- Layouts sized to `100vh` overflow by 44px.\n- A fixed or sticky element inside a web component's shadow root is not moved, and sits under the bar.\n- An element inside a web component's shadow root is pinned as the component itself.\n- A pin's selector is the path to the element when it was picked\n(`#summary > p:nth-of-type(3)` ). After the agent's changes it may find a\ndifferent element, and a sent pin's dimmed marker then sits on that one.\n- The window is Chrome for Testing, not your own browser: it has no profile, logins or extensions.\n- **The page under review shares a JavaScript realm with the bar.** It is the\ncode the agent is editing, so it must not be able to approve. Submit and\nApprove act only on trusted clicks. The session refuses changes from frames\nand from pages that are not http or https. Before any page script runs, the\ninit script takes the binding off`window` , puts a sealed stand-in over\nPlaywright's binding controller, and hides the raw DevTools binding. It\nalso stops sending if the page has patched`JSON.stringify` , or put a`toJSON` or index setter on the prototypes.`scripts/spikes/loop-check.ts` checks each of these. They depend on Playwright internals, and a page\ndetermined enough to patch other builtins on the call path may still find\na way in. The real fix is running the bar in an isolated world.\n\nTo pin comments to elements, the bar needs to reach the page's DOM.\nA cross-origin iframe can't do that, and `X-Frame-Options` or a CSP can refuse\nframing altogether. That leaves two choices: a proxy that injects the bar\ninto the HTML it serves, or a browser that injects it for us. Gloss drives\nChromium with Playwright, and registers the bar with `addInitScript` and\n`exposeBinding` on the browser context:\n\n- **The page is untouched.** It loads from its real origin. A proxy would\nhave to decompress and rewrite HTML and absolute URLs, relay the dev\nserver's HMR websocket, strip CSP and frame headers, and keep cookies and\nredirects from escaping to the real origin.\n- **A strict CSP doesn't stop it.** Init scripts run whatever the page's CSP\nsays. The bar's styles are constructed stylesheets rather than`<style>` tags, and it talks to the session through the exposed binding rather than`fetch` , so neither`style-src` nor`connect-src` gets in the way.\n- **It follows you.** New tabs, client-side navigation and full reloads all\nget the bar again.\n- **HTTPS dev hosts load** , with`ignoreHTTPSErrors` , as in Reeve's screenshots.\n\nThe cost is the Chromium download and a window that isn't your everyday\nbrowser. If Chromium is missing, `gloss open` exits 1 and says to run\n`gloss install-chromium`.\n\n```\nnpm test              # unit tests\nnpm run typecheck\nnpm run dev -- -port 4400\nnpm run schema        # rewrite schema/verdict.v1.json from src/verdict.ts\nnpx tsx scripts/spikes/open-check.ts   # end to end, headless; needs Chromium\nnpx tsx scripts/spikes/loop-check.ts   # the review loop: rounds, working, ready, approve, no-verdict cases\nnpx tsx scripts/spikes/nav-check.ts    # links, forms, client nav, X-Frame-Options: DENY\n```\n\nCI runs all three spikes on every push and pull request, in\n`.github/workflows/ci.yml`. It also installs the packed tarball the way\nHomebrew does and runs it on node 22 and 26, and lints the formula.\n[docs/releasing.md](https://github.com/ryanbrunner/gloss/blob/main/docs/releasing.md) covers releases.\n\n`npm run dev` serves a fixture storefront to point `gloss open` at. It also\nreads `--port` and `PORT`. Query flags make each state of the bar reachable\nby URL, using the same bar with its comments kept in the page. `&pins=N` pins\nthe first N comments, sent ones first, and `&pick=N` picks the Nth of the\nsame elements; both take numbers, since a selector's `#` would end the query:\n\n| URL |  | \n|---|---|\n| `/` | the storefront, no bar | \n| `/?gloss` | the bar, empty | \n| `/?gloss&seed=2&list` | two comments, with the list open | \n| `/?gloss&sent=3&seed=1&list` | three comments sent over two rounds, one new | \n| `/?gloss&phase=submitted&sent=2` | round 1 sent, waiting for Claude | \n| `/?gloss&phase=working&msg=…` | Claude working, with its message | \n| `/?gloss&summary=…&sent=2` | back with the reviewer, showing Claude's summary | \n| `/?gloss&seed=2&confirm` | the discard-and-approve prompt | \n| `/?gloss&phase=approved&sent=2` | approved | \n| `/?gloss&select` | Select mode, nothing picked | \n| `/?gloss&select&pick=2` | Select mode, commenting on the order total | \n| `/?gloss&seed=3&pins=3` | three comments pinned to storefront elements, with markers | \n| `/?gloss&sent=2&seed=1&pins=3` | two sent pins, dimmed, beside a new one | \n| `/?fixed` | a `position: fixed` header | \n| `/?csp` | served with a strict Content-Security-Policy | \n\nTwo environment variables exist for the spike: `GLOSS_HEADLESS=1` runs the\nsession's Chromium headless, and `GLOSS_CDP_PORT` opens a DevTools port on it\nso the spike can look inside the window.", "url": "https://wpnews.pro/news/gloss-annotate-live-code-and-provide-feedback-to-an-agent", "canonical_source": "https://github.com/ryanbrunner/gloss", "published_at": "2026-10-03 02:08:31+00:00", "updated_at": "2026-10-03 02:36:16.760389+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["Gloss", "Ryan Brunner", "Claude", "Claude Code", "Reeve", "Chromium", "Playwright", "Homebrew"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/gloss-annotate-live-code-and-provide-feedback-to-an-agent", "markdown": "https://wpnews.pro/news/gloss-annotate-live-code-and-provide-feedback-to-an-agent.md", "text": "https://wpnews.pro/news/gloss-annotate-live-code-and-provide-feedback-to-an-agent.txt", "jsonld": "https://wpnews.pro/news/gloss-annotate-live-code-and-provide-feedback-to-an-agent.jsonld"}}