May 30, 2026 • 6 min read • Agentic Harness Engineering

The Page Feedback Widget: Closing the Loop Between Browser and Agent

A lightweight Chrome extension that injects a floating feedback panel on every local page. Notes are written to localStorage and POSTed to the harness API, where they land in data/page_feedback.jsonl — a structured queue that agents can read and act on directly.

The harness manages a large and growing set of web pages: blog posts, dashboard views, skill documentation. Keeping them accurate requires a feedback loop — a way to leave a note on a page while looking at it and have that note reach the agent that can fix it. The alternative is a side channel: a Slack message, a sticky note, a mental todo that evaporates. The widget replaces all of that with a chat icon that lives in the corner of every local page.

Architecture

The system has three components: a Chrome extension that injects the widget, a FastAPI endpoint that receives and stores notes, and the page_feedback.jsonl file that agents read. Each piece is independent — the extension works when the harness is offline (notes buffer in localStorage), and the harness can query notes without the browser being open.

browser-extension/

Unpacked Chrome extension. Content script injected into every localhost and file:/// page. No build step — load with "Load unpacked" in chrome://extensions.

POST /api/page-feedback

FastAPI endpoint in harness/api/routes/feedback.py. Accepts {url, title, feedback}, appends a JSON record to data/page_feedback.jsonl, returns the record.

data/page_feedback.jsonl

Newline-delimited JSON. Each record has id, url, title, feedback, and created_at. Agents read this file directly or query via GET /api/page-feedback?url=....

The extension

The extension is a Manifest V3 content script with no dependencies, no build step, and no external calls except to the local harness API. Two files: manifest.json and content.js. The manifest matches http://localhost/*, http://127.0.0.1/*, and file:///* — the last pattern requires enabling "Allow access to file URLs" in the extension's detail page after loading.

{
  "manifest_version": 3,
  "name": "Harness Page Feedback",
  "content_scripts": [{
    "matches": ["http://localhost/*", "http://127.0.0.1/*", "file:///*"],
    "js": ["content.js"],
    "run_at": "document_idle"
  }],
  "host_permissions": ["http://localhost/*", "http://127.0.0.1/*"]
}

Shadow DOM isolation

The widget is injected into a shadow root so the host page's CSS cannot affect it and the widget's CSS cannot leak out. This matters on pages with aggressive global resets or conflicting z-index stacks. The host element is a div positioned fixed at the chosen corner with z-index: 2147483647 — the maximum value — so it floats above everything.

const host = document.createElement('div');
host.id = '__harness_fb_widget__';
Object.assign(host.style, { position: 'fixed', zIndex: '2147483647' }, cornerCSS(pickCorner()));
document.body.appendChild(host);

const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>...self-contained styles...</style> <!-- widget HTML -->`;

Corner detection

The widget picks a corner automatically by scanning all fixed-position elements on the page and choosing the least-occupied corner. Many pages have their own floating controls — a cookie banner, a chat widget, a back-to-top button. Without corner detection, the feedback button would land on top of them.

function pickCorner() {
  const corners = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
  const fixed = Array.from(document.querySelectorAll('*')).filter(el =>
    window.getComputedStyle(el).position === 'fixed' && el.id !== WIDGET_ID
  );
  function occupied(corner) {
    for (const el of fixed) {
      const r = el.getBoundingClientRect();
      const w = window.innerWidth, h = window.innerHeight;
      if (corner === 'bottom-right' && r.right > w - 100 && r.bottom > h - 100) return true;
      // ... other corners
    }
    return false;
  }
  return corners.find(c => !occupied(c)) || 'bottom-right';
}

The 100px threshold is intentionally generous — a button at right: 20px; bottom: 20px occupies the corner even if its bounding rect doesn't literally touch the window edge.

Badge, checklist, and completion state

The chat button shows a green badge with the count of notes for the current URL. Opening the panel renders existing notes as a checklist above the textarea. Each item has a circular checkbox; checking it applies a strikethrough with a CSS transition and persists the completed flag to localStorage. This gives a Wunderlist-style review flow: open the panel, scan what's been noted, mark things done as they're fixed, add new notes as needed.

The panel state is driven entirely by localStorage — the checklist re-renders from harness_page_feedback on every open. The completed state of each note is keyed by its id field, so toggling completion on one device doesn't interfere with notes from another session.

Dual persistence and the file:/// CORS problem

Every submission writes to localStorage first (synchronously, before the network call), then attempts a POST to http://localhost:7860/api/page-feedback. If the harness is offline, the note is saved locally and the status line says so. This means the widget is useful even during local development when the server isn't running.

Pages served from file:/// URLs present a CORS problem. Browsers send a null origin for file:// requests — not http://localhost or any real origin, just the string "null". FastAPI's default allow_origin_regex matching against http://localhost patterns doesn't match it. The fix is explicit:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["null"],                                      # file:// pages
    allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?", # dev servers
    allow_methods=["*"],
    allow_headers=["*"],
)

The "null" origin allowance is intentionally scoped to the feedback endpoint's router. Any route that accepts null origin should be read-only or append-only — never expose mutation endpoints with privileged side effects to file:// origins.

The API endpoints

Three endpoints in harness/api/routes/feedback.py handle page feedback, separate from the existing run-level feedback routes:

class PageFeedbackBody(BaseModel):
    url:      str
    title:    str = ""
    feedback: str

@router.post("/page-feedback")
async def post_page_feedback(body: PageFeedbackBody):
    record = {
        "id":         uuid.uuid4().hex[:12],
        "url":        body.url,
        "title":      body.title,
        "feedback":   body.feedback,
        "created_at": datetime.now(UTC).isoformat(),
    }
    with open(PAGE_FEEDBACK_FILE, "a", encoding="utf-8") as f:
        f.write(json.dumps(record) + "\n")
    return record

@router.get("/page-feedback")
async def get_page_feedback(url: str | None = None): ...

@router.delete("/page-feedback")
async def delete_page_feedback(url: str): ...

The DELETE endpoint rewrites the JSONL file in place, keeping all records whose url field doesn't match the query parameter. It's called by the "Clear notes for this page" button in the widget, which also clears localStorage for the same URL simultaneously.

How agents read the notes

The intended workflow is simple: the agent reads data/page_feedback.jsonl directly, or queries GET /api/page-feedback?url=... for notes on a specific page, makes the described changes, then calls DELETE /api/page-feedback?url=... to clear resolved items. The widget's "Clear notes for this page" button does the same thing from the browser side.

Because notes include the full page URL and title, an agent can process all outstanding feedback in one pass — read the file, group by URL, fix each page, delete as it goes. The completed flag in localStorage tracks human-side review status; the JSONL file tracks what the agent still needs to act on. They're complementary rather than redundant.

Dismissing the widget

Hovering over the chat button reveals a small dismiss button in the top-left corner of the button. Clicking it removes the widget from the DOM entirely for that page session — no badge, no button, no panel. It reappears on the next page load. This is useful on pages where the widget obscures content you need to see, or when you've already reviewed all the notes and want it out of the way.

Loading the extension

1
Open Chrome and go to chrome://extensions

Toggle "Developer mode" on in the top-right corner.

2
Click "Load unpacked"

Navigate to browser-extension/ in the harness repo and select the folder.

3
Enable file URL access

Click the extension's "Details" button and toggle "Allow access to file URLs" to reach file:/// pages.

4
Open any local page

The chat icon appears in the least-occupied corner. Click to open the feedback panel, write a note, and submit. Notes land in data/page_feedback.jsonl when the harness is running.