<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Haidar Esber</title>
    <description>The latest articles on DEV Community by Haidar Esber (@haidaresber).</description>
    <link>https://dev.to/haidaresber</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4234175%2Fca375422-fb32-4380-9790-cf0f49958a67.jpg</url>
      <title>DEV Community: Haidar Esber</title>
      <link>https://dev.to/haidaresber</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/haidaresber"/>
    <language>en</language>
    <item>
      <title>PrivaSVG: image-to-SVG vectorization in the browser with Rust and WebAssembly</title>
      <dc:creator>Haidar Esber</dc:creator>
      <pubDate>Sun, 08 Nov 2026 22:42:22 +0000</pubDate>
      <link>https://dev.to/haidaresber/privasvg-image-to-svg-vectorization-in-the-browser-with-rust-and-webassembly-4inb</link>
      <guid>https://dev.to/haidaresber/privasvg-image-to-svg-vectorization-in-the-browser-with-rust-and-webassembly-4inb</guid>
      <description>&lt;p&gt;Most image-to-SVG converters are upload forms: your file goes to a server, gets traced, and comes back. &lt;a href="https://github.com/HaidarESBER/privasvg" rel="noopener noreferrer"&gt;PrivaSVG&lt;/a&gt; does the whole job in the browser tab. There is no backend. Here's how it's built, with the parts that were actually tricky.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Raster image -&amp;gt; AI background removal -&amp;gt; Manual retouch -&amp;gt; Upscale (2x / 4x) -&amp;gt; Vectorize (Rust -&amp;gt; WASM) -&amp;gt; SVG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stage is optional except the last, so you can jump straight to vectorization. A bulk page runs up to 50 images through a fixed chain and downloads a single ZIP.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vectorization:&lt;/strong&gt; a Rust engine compiled to WebAssembly with &lt;code&gt;wasm-bindgen&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background removal:&lt;/strong&gt; &lt;code&gt;@imgly/background-removal&lt;/code&gt;, which runs the &lt;code&gt;isnet_fp16&lt;/code&gt; segmentation model on ONNX Runtime Web. The model is fetched once, cached, and then works offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upscaling:&lt;/strong&gt; 2x or 4x canvas resampling to recover detail before tracing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retouch:&lt;/strong&gt; a brush-based remove/restore tool to fix edges the model missed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Credit where it's due
&lt;/h2&gt;

&lt;p&gt;The tracing core is not from scratch. The Rust engine is built on &lt;a href="https://github.com/visioncortex/vtracer" rel="noopener noreferrer"&gt;VTracer&lt;/a&gt; and the &lt;a href="https://github.com/visioncortex/visioncortex" rel="noopener noreferrer"&gt;visioncortex&lt;/a&gt; crate (the app depends on &lt;code&gt;visioncortex&lt;/code&gt; 0.8.1). What PrivaSVG puts around it is the browser pipeline: the background-removal and retouch stages, upscaling and bulk processing. The keying approach described below follows the same idea as the upstream VTracer web app. The project is dual-licensed MIT OR Apache-2.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracing as an incremental state machine
&lt;/h2&gt;

&lt;p&gt;Tracing a large image is slow work, so the converter is an incremental state machine that the UI drives with repeated &lt;code&gt;tick()&lt;/code&gt; calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Stage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;Clustering&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IncrementalBuilder&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Reclustering&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IncrementalBuilder&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Vectorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Clusters&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;tick()&lt;/code&gt; does a small slice of work and returns whether it's finished. &lt;code&gt;Clustering&lt;/code&gt; groups pixels into color clusters. In &lt;code&gt;cutout&lt;/code&gt; mode a second &lt;code&gt;Reclustering&lt;/code&gt; pass runs on the cluster view. &lt;code&gt;Vectorize&lt;/code&gt; then converts one cluster per tick into a compound path and prepends it to the target &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt;. A &lt;code&gt;progress()&lt;/code&gt; method maps the stages onto 0 to 100 (clustering is the first half, vectorizing the second), so the UI can show a real progress bar. Because the work is split into small slices, control returns to the caller between ticks.&lt;/p&gt;

&lt;p&gt;Parameters arrive as JSON (&lt;code&gt;new_with_string&lt;/code&gt;) and are deserialized with &lt;code&gt;serde&lt;/code&gt;. They are the same knobs the settings panel exposes: curve-fitting mode (pixel, polygon, spline), speckle filter, color precision, layer difference, corner threshold, splice threshold and path precision, plus a stacked or cutout hierarchy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transparency problem
&lt;/h2&gt;

&lt;p&gt;Background removal produces transparent pixels, but a clustering tracer wants colors. If you let transparent pixels through as black, you get a black blob behind your subject in the SVG.&lt;/p&gt;

&lt;p&gt;The converter handles this with a &lt;strong&gt;key color&lt;/strong&gt;. In &lt;code&gt;init()&lt;/code&gt; it finds a color that isn't used anywhere in the image, replaces transparent pixels with it (including near-transparent black pixels, which the canvas API can produce when it flattens alpha), and tells the clustering runner to &lt;code&gt;Discard&lt;/code&gt; that color. The key color never appears in the output, so transparent regions stay transparent. If no unused color can be found, it falls back to a fixed, very unlikely cyan (&lt;code&gt;1, 254, 253&lt;/code&gt;). The source comments also record a lesson learned: red as the first-choice key color caused visible red backgrounds, so less common colors are tried first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy is the architecture, not a policy
&lt;/h2&gt;

&lt;p&gt;There's no server to trust because there's no server. Images never leave the tab, and there are no accounts or tracking. The WASM engine and the ONNX model both run on your device. That's a stronger guarantee than a privacy policy, and it's why the project shares its visual language with my other privacy project, &lt;a href="https://github.com/HaidarESBER/privasearch" rel="noopener noreferrer"&gt;PrivaSearch&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The tracing quality is the visioncortex/VTracer algorithm's. It works well for logos, illustrations and cut-out subjects, and it will not turn a photograph into a faithful vector.&lt;/li&gt;
&lt;li&gt;The first background removal downloads the model, so it needs the network once.&lt;/li&gt;
&lt;li&gt;The cutout-mode transparency handling is heuristic, as the "fallback key color" above shows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Live demo: &lt;a href="https://haidaresber.github.io/privasvg/" rel="noopener noreferrer"&gt;https://haidaresber.github.io/privasvg/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source (Rust, WASM, webpack): &lt;a href="https://github.com/HaidarESBER/privasvg" rel="noopener noreferrer"&gt;https://github.com/HaidarESBER/privasvg&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>webassembly</category>
      <category>privacy</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Building an Arabic-first RTL news platform with Next.js 16 and Express</title>
      <dc:creator>Haidar Esber</dc:creator>
      <pubDate>Sun, 08 Nov 2026 22:41:59 +0000</pubDate>
      <link>https://dev.to/haidaresber/building-an-arabic-first-rtl-news-platform-with-nextjs-16-and-express-1gig</link>
      <guid>https://dev.to/haidaresber/building-an-arabic-first-rtl-news-platform-with-nextjs-16-and-express-1gig</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/HaidarESBER/majalpost" rel="noopener noreferrer"&gt;Majal Post&lt;/a&gt; (مجال بوست) is an Arabic-first news and media platform focused on Lebanese social, economic and environmental issues. I built it as a monorepo: a Next.js frontend, an Express/MongoDB REST API, and a shared TypeScript types package. This post covers the decisions that mattered most, and what I'd still improve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Node.js with Express 5 (ESM), TypeScript, MongoDB via Mongoose, JWT auth with bcrypt&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Media:&lt;/strong&gt; Cloudinary, Multer and Sharp for upload, resizing and thumbnails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email:&lt;/strong&gt; Nodemailer over SMTP, silently skipped when SMTP isn't configured so the rest of the app keeps working&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared:&lt;/strong&gt; a private &lt;code&gt;@majalpost/shared&lt;/code&gt; package so the frontend and backend agree on types&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The product side covers themed categories (Environment &amp;amp; Climate, Society, Economy, Education, Technology, Health), articles with slugs and tags, search, comments, an admin dashboard, and a contributor workflow: a public "become a contributor" application plus per-author "my articles" pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arabic-first means RTL at the root
&lt;/h2&gt;

&lt;p&gt;The simplest RTL decision was also the most important one: set it once, at the top of the tree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"ar"&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"rtl"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;dir="rtl"&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, flexbox and text flow mirror automatically, so most layouts need no per-component handling. Fonts are loaded through &lt;code&gt;next/font/google&lt;/code&gt; with the Arabic subset: Almarai (weights 300 to 800) and IBM Plex Sans Arabic (300 to 700), exposed as CSS variables.&lt;/p&gt;

&lt;p&gt;Where it's not finished: I didn't use Tailwind's logical utilities (&lt;code&gt;ms-*&lt;/code&gt;, &lt;code&gt;me-*&lt;/code&gt;, &lt;code&gt;ps-*&lt;/code&gt;, &lt;code&gt;pe-*&lt;/code&gt;). A handful of physical &lt;code&gt;ml-&lt;/code&gt;/&lt;code&gt;mr-&lt;/code&gt;/&lt;code&gt;pl-&lt;/code&gt;/&lt;code&gt;pr-&lt;/code&gt; utilities remain, and the header uses &lt;code&gt;space-x-reverse&lt;/code&gt;. They work today because the site is RTL-only, but logical properties would be the right move if I ever add a left-to-right locale.&lt;/p&gt;

&lt;h2&gt;
  
  
  An API that assumes the internet is hostile
&lt;/h2&gt;

&lt;p&gt;The backend is where most of the hardening lives, all visible in &lt;code&gt;index.ts&lt;/code&gt; and &lt;code&gt;middleware/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Helmet&lt;/strong&gt; with a content security policy (&lt;code&gt;defaultSrc: 'self'&lt;/code&gt;, images allowed from self, &lt;code&gt;data:&lt;/code&gt;, &lt;code&gt;blob:&lt;/code&gt; and the frontend origin) and HSTS (one year, including subdomains, preload).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A CORS allowlist&lt;/strong&gt; driven by &lt;code&gt;FRONTEND_URL&lt;/code&gt; (comma-separated), rejecting any other origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three rate limiters:&lt;/strong&gt; 100 requests per 15 minutes on the general API, a stricter 20 per 15 minutes on auth that doesn't count successful requests, and 10 uploads per hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 10 MB body limit&lt;/strong&gt; on JSON and URL-encoded bodies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role-based access:&lt;/strong&gt; &lt;code&gt;authorize.ts&lt;/code&gt; provides &lt;code&gt;requireAdmin&lt;/code&gt; and &lt;code&gt;requireAdminOrEditor&lt;/code&gt; middleware that runs after JWT authentication.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sanitize on the server, not just in the editor
&lt;/h2&gt;

&lt;p&gt;Article bodies and comments are HTML, which is an XSS risk. Article routes sanitize on write: titles and excerpts go through &lt;code&gt;sanitizeText&lt;/code&gt; (all markup stripped), and content goes through &lt;code&gt;sanitizeHtml&lt;/code&gt;, a DOMPurify instance running on a jsdom window with an allowlist of tags (&lt;code&gt;p&lt;/code&gt;, &lt;code&gt;br&lt;/code&gt;, &lt;code&gt;strong&lt;/code&gt;, &lt;code&gt;em&lt;/code&gt;, &lt;code&gt;h1&lt;/code&gt;-&lt;code&gt;h6&lt;/code&gt;, lists, &lt;code&gt;blockquote&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;img&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;pre&lt;/code&gt;) and attributes (&lt;code&gt;href&lt;/code&gt;, &lt;code&gt;src&lt;/code&gt;, &lt;code&gt;alt&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;class&lt;/code&gt;). Comments are sanitized the same way.&lt;/p&gt;

&lt;p&gt;This is covered by backend unit tests (run in CI): script tags and inline handlers are stripped, &lt;code&gt;javascript:&lt;/code&gt; URLs are blocked, safe links and formatting survive, and Arabic text is preserved unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's honestly not done
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The test suite is small: 11 backend unit tests (sanitization and ObjectId validation). The frontend has none yet.&lt;/li&gt;
&lt;li&gt;The frontend lint was failing with 11 errors until recently. They're fixed and lint is now a blocking CI step, but 45 warnings remain.&lt;/li&gt;
&lt;li&gt;The auth rate limiter's comment notes the limit was raised for testing, and it's worth revisiting before treating it as final.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose up &lt;span class="nt"&gt;-d&lt;/span&gt;        &lt;span class="c"&gt;# MongoDB 7 on localhost:27017&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;backend &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run dev     &lt;span class="c"&gt;# http://localhost:5000/api&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;frontend &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run dev    &lt;span class="c"&gt;# http://localhost:3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/HaidarESBER/majalpost" rel="noopener noreferrer"&gt;https://github.com/HaidarESBER/majalpost&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>express</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PrivaSearch: private AI search with SearXNG and a local LLM</title>
      <dc:creator>Haidar Esber</dc:creator>
      <pubDate>Sun, 08 Nov 2026 22:35:38 +0000</pubDate>
      <link>https://dev.to/haidaresber/privasearch-private-ai-search-with-searxng-and-a-local-llm-5hk0</link>
      <guid>https://dev.to/haidaresber/privasearch-private-ai-search-with-searxng-and-a-local-llm-5hk0</guid>
      <description>&lt;p&gt;I wanted AI-synthesized search answers without sending my queries to anyone. &lt;a href="https://github.com/HaidarESBER/privasearch" rel="noopener noreferrer"&gt;PrivaSearch&lt;/a&gt; is the result: &lt;a href="https://docs.searxng.org/" rel="noopener noreferrer"&gt;SearXNG&lt;/a&gt; for metasearch, a local LLM served by &lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; for cited answers, and a small Node/Express API in between. Here's how it's put together and what it does and doesn't guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture is the privacy model
&lt;/h2&gt;

&lt;p&gt;Four containers on one internal Docker network (&lt;code&gt;privanet&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;frontend&lt;/strong&gt;: nginx serving a React/Vite UI and proxying &lt;code&gt;/api&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;api&lt;/strong&gt;: Node + Express orchestration, streaming over SSE&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;searxng&lt;/strong&gt;: the metasearch backend, with a JSON API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ollama&lt;/strong&gt;: the local LLM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only the frontend publishes a host port (&lt;code&gt;8080&lt;/code&gt; by default). SearXNG, Ollama and the API have no &lt;code&gt;ports:&lt;/code&gt; mapping, so they're unreachable from outside Docker. The only routine outbound traffic is SearXNG querying upstream search engines, which is the fundamental job of a metasearch engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search and answer are two separate calls
&lt;/h2&gt;

&lt;p&gt;The browser first asks &lt;code&gt;GET /api/results&lt;/code&gt; for paginated SearXNG results, then calls &lt;code&gt;POST /api/answer&lt;/code&gt; with the top N of those results to stream a RAG answer. Splitting them means you can change how many sources the model considers and regenerate the answer without re-querying the web.&lt;/p&gt;

&lt;p&gt;The answer handler is short. It takes the caller's sources, builds the prompt, and streams tokens as they arrive from Ollama:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;streamChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TEMPERATURE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ollama's &lt;code&gt;/api/chat&lt;/code&gt; returns newline-delimited JSON when &lt;code&gt;stream: true&lt;/code&gt;, and &lt;code&gt;ollama.js&lt;/code&gt; parses it line by line. If the client disconnects, an &lt;code&gt;AbortController&lt;/code&gt; cancels the generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt discipline instead of hope
&lt;/h2&gt;

&lt;p&gt;The system prompt (in &lt;code&gt;prompt.js&lt;/code&gt;) constrains the model to the numbered results: write one direct answer in your own words, don't summarize sources one by one, cite each claim with &lt;code&gt;[n]&lt;/code&gt;, never add outside knowledge, and if the results don't contain enough information, reply with exactly "Insufficient information in the results." Each source is reduced to one line, &lt;code&gt;[index] title — snippet&lt;/code&gt;, with no URLs, because the model doesn't need them and echoing them encourages source-by-source listing. The default temperature is &lt;code&gt;0.2&lt;/code&gt; for factual answers. These prompt builders have unit tests that run in CI.&lt;/p&gt;

&lt;p&gt;If the LLM is down or the model isn't pulled, the raw SearXNG results still render with a notice. If there are no results, you get an explicit "no results" message rather than a generated answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Few dependencies on purpose
&lt;/h2&gt;

&lt;p&gt;The API's only runtime dependency is Express. Node's built-in &lt;code&gt;fetch&lt;/code&gt; covers HTTP. Even the SOCKS5 client used to discover the Tor exit IP is a small dependency-free module (&lt;code&gt;socks.js&lt;/code&gt;, about 60 lines). For a privacy project, fewer dependencies means less attack surface and less to audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional: hide from the engines too
&lt;/h2&gt;

&lt;p&gt;By default, upstream engines see your real IP. Two optional overrides route SearXNG through a tunnel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tor&lt;/strong&gt; (&lt;code&gt;npm run up-tor&lt;/code&gt;): SearXNG goes through Tor's SOCKS proxy using &lt;code&gt;socks5h://&lt;/code&gt;, so DNS also resolves through Tor. The trade-off is that it's slower, and some engines block Tor exit nodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPN&lt;/strong&gt; (&lt;code&gt;npm run up-vpn&lt;/code&gt;): SearXNG runs inside Gluetun's network namespace. Gluetun's kill switch is on by default, so if the tunnel drops, searches fail closed rather than leaking your real IP.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What touches disk
&lt;/h2&gt;

&lt;p&gt;Per the privacy statement in the README: your query lives only in API and SearXNG process memory, search results are held only for the request, and generated answers are streamed to your browser and not stored. What is persisted is the model weights (the &lt;code&gt;ollama-models&lt;/code&gt; volume) and the SearXNG config. SearXNG is hardened with query logging off, no Redis attached, autocomplete off (no per-keystroke leakage) and remote images proxied. Thumbnails go through &lt;code&gt;/api/image_proxy&lt;/code&gt;, so your browser never contacts remote image hosts, and the API refuses to proxy anything that isn't an image.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest exceptions
&lt;/h2&gt;

&lt;p&gt;Two opt-in features make extra requests, and both can be disabled: &lt;strong&gt;Summarize&lt;/strong&gt; fetches the one page you click (&lt;code&gt;ALLOW_PAGE_FETCH=false&lt;/code&gt; turns it off), and the exit-location widget does one cached geo-IP lookup (&lt;code&gt;SHOW_LOCATION=false&lt;/code&gt; turns it off). Both are disclosed in the app's privacy panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance reality
&lt;/h2&gt;

&lt;p&gt;The default model is &lt;code&gt;qwen2.5:1.5b&lt;/code&gt; (about 1 GB), chosen for CPU: it loads in seconds and the README reports about 8 s per warm answer. &lt;code&gt;qwen2.5:0.5b&lt;/code&gt; is faster but weaker at citations, and &lt;code&gt;llama3.1:8b&lt;/code&gt; gives better answers but is slow on CPU. The model is kept resident for 30 minutes (&lt;code&gt;OLLAMA_KEEP_ALIVE&lt;/code&gt;) so you only pay the load cost once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run setup
npm run pull-model
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;. Source: &lt;a href="https://github.com/HaidarESBER/privasearch" rel="noopener noreferrer"&gt;https://github.com/HaidarESBER/privasearch&lt;/a&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>selfhosted</category>
      <category>ai</category>
      <category>docker</category>
    </item>
    <item>
      <title>Making browser perception cheap for LLM agents: snapshots, caching and diffs</title>
      <dc:creator>Haidar Esber</dc:creator>
      <pubDate>Sun, 08 Nov 2026 22:35:07 +0000</pubDate>
      <link>https://dev.to/haidaresber/making-browser-perception-cheap-for-llm-agents-snapshots-caching-and-diffs-3inm</link>
      <guid>https://dev.to/haidaresber/making-browser-perception-cheap-for-llm-agents-snapshots-caching-and-diffs-3inm</guid>
      <description>&lt;p&gt;Most "give the model a browser" setups hand it one of two things: a screenshot, or a dump of the full accessibility tree. Both are expensive, and both get re-sent in full after every action.&lt;/p&gt;

&lt;p&gt;I've been building &lt;a href="https://github.com/HaidarESBER/ai-browser" rel="noopener noreferrer"&gt;&lt;code&gt;ecobrowser&lt;/code&gt;&lt;/a&gt;, a TypeScript library and MCP server on top of Playwright whose whole job is to make perception cheap for a model. This post walks through three pieces of it, using the real code: a compact snapshot format, a cache keyed on the DOM, and a diff for re-perceiving after an action.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A snapshot the model can act on
&lt;/h2&gt;

&lt;p&gt;A snapshot is not a picture of the page. It's a list of interactive elements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SnapshotElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// durable id, e.g. "e3"&lt;/span&gt;
  &lt;span class="nl"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;state&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;   &lt;span class="c1"&gt;// disabled, checked, expanded, obscured, ...&lt;/span&gt;
  &lt;span class="nl"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// a &amp;lt;select&amp;gt;'s choices&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Snapshot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SnapshotElement&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each element is stamped with a durable &lt;code&gt;data-ai-id&lt;/code&gt;, and the model acts by id (&lt;code&gt;click e3&lt;/code&gt;), never by CSS selector or pixel coordinate. The &lt;code&gt;state&lt;/code&gt; array carries what the model needs to act correctly, and covered elements are flagged &lt;code&gt;obscured&lt;/code&gt;. Password values are never captured and show as &lt;code&gt;[redacted]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The snapshot is capped at 250 elements, and it only covers interactive elements. That's a deliberate scope choice, and it matters for the benchmark below.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cache the snapshot until the DOM changes
&lt;/h2&gt;

&lt;p&gt;Re-walking the DOM and re-serializing on every call is wasteful when nothing changed. The engine runs a &lt;code&gt;MutationObserver&lt;/code&gt; that counts real DOM changes (ignoring its own &lt;code&gt;data-ai-id&lt;/code&gt; stamping), and &lt;code&gt;snapshot()&lt;/code&gt; serves the previous result when the DOM version and URL match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;domVersion&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastCacheable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastSnapshot&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastDomVersion&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastSnapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastSnapshot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip the full DOM walk + serialization&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...otherwise build a fresh snapshot&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Re-perceive with a diff, not a full page
&lt;/h2&gt;

&lt;p&gt;After a click, the model rarely needs the whole page again. &lt;code&gt;changes()&lt;/code&gt; returns only the delta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SnapshotDiff&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;added&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SnapshotElement&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SnapshotElement&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;from&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[];&lt;/span&gt;
  &lt;span class="nl"&gt;unchanged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is how elements are matched between the two snapshots. Ids that appear exactly once in both are matched by id, so a relabelled button shows up as &lt;code&gt;changed&lt;/code&gt; rather than as one removal plus one addition. Everything else is matched by a semantic signature (&lt;code&gt;tag|role|name&lt;/code&gt;) plus an occurrence index, so five identical buttons are matched positionally instead of collapsing into one key. A toggled checkbox counts as changed because the comparison includes &lt;code&gt;value&lt;/code&gt; and the &lt;code&gt;state&lt;/code&gt; flags, not just the label.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;find()&lt;/code&gt; without an LLM
&lt;/h2&gt;

&lt;p&gt;For "just give me the search box", there's a deterministic scorer, &lt;code&gt;elementMatchScore&lt;/code&gt;. Words like &lt;code&gt;box&lt;/code&gt;, &lt;code&gt;field&lt;/code&gt;, &lt;code&gt;button&lt;/code&gt; and &lt;code&gt;link&lt;/code&gt; are treated as &lt;em&gt;kind&lt;/em&gt; hints and matched against tag and role. The remaining words are scored against the accessible name: exact beats prefix beats substring beats all-words-present. A kind match boosts the score and a kind mismatch demotes it, so "search box" ranks the search input above a "Search" button. It's cheap precisely because no model is involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers say (and don't)
&lt;/h2&gt;

&lt;p&gt;The repo includes a head-to-head benchmark against Playwright MCP (&lt;code&gt;npm run bench&lt;/code&gt;) on the same page, measuring bytes returned to the model and tool latency:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;ecobrowser&lt;/th&gt;
&lt;th&gt;Playwright MCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full page snapshot&lt;/td&gt;
&lt;td&gt;~41 KB (~10K tokens)&lt;/td&gt;
&lt;td&gt;~128 KB (~32K tokens)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-perceive latency&lt;/td&gt;
&lt;td&gt;~5 ms (cache hit)&lt;/td&gt;
&lt;td&gt;~150 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-perceive after an action&lt;/td&gt;
&lt;td&gt;delta only&lt;/td&gt;
&lt;td&gt;full page again&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The caveats matter, and they're in the README too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This measures &lt;strong&gt;perception payload and tool latency&lt;/strong&gt;, not end-to-end LLM wall-clock. No live model ran.&lt;/li&gt;
&lt;li&gt;Part of the size gap is &lt;strong&gt;scope&lt;/strong&gt;: ecobrowser captures interactive elements only, while Playwright MCP captures the full accessibility tree.&lt;/li&gt;
&lt;li&gt;It's faster than the &lt;em&gt;wrapper&lt;/em&gt; (Playwright MCP), not than Playwright itself, which is the shared engine under both. The wins are caching, diffing and a leaner format, all of which a competitor could adopt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;It's a young, single-user developer tool, tested on its own paths (34 unit tests, 21 browser regression tests and 7 MCP-level regression tests, all run in CI) but not battle-hardened across thousands of real sites. It's meant to be pointed at your own or authorized sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add ecobrowser &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; ecobrowser-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or &lt;code&gt;npm install ecobrowser&lt;/code&gt; and use it as a library. Source: &lt;a href="https://github.com/HaidarESBER/ai-browser" rel="noopener noreferrer"&gt;https://github.com/HaidarESBER/ai-browser&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>mcp</category>
      <category>playwright</category>
    </item>
    <item>
      <title>Self-healing browser actions for AI agents (and why they must refuse to guess)</title>
      <dc:creator>Haidar Esber</dc:creator>
      <pubDate>Sun, 08 Nov 2026 22:29:39 +0000</pubDate>
      <link>https://dev.to/haidaresber/self-healing-browser-actions-for-ai-agents-and-why-they-must-refuse-to-guess-2ko1</link>
      <guid>https://dev.to/haidaresber/self-healing-browser-actions-for-ai-agents-and-why-they-must-refuse-to-guess-2ko1</guid>
      <description>&lt;p&gt;When an LLM drives a browser, the classic failure isn't "the model is dumb". It's that the page&lt;br&gt;
&lt;strong&gt;moved under its feet&lt;/strong&gt;. The agent reads the page, decides to click &lt;code&gt;e3&lt;/code&gt;, and by the time the click&lt;br&gt;
lands, a framework re-render has replaced that DOM node with an identical-looking new one. The id is&lt;br&gt;
gone, the click fails, and the model burns tokens re-reading the page and re-deciding.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ecobrowser&lt;/code&gt; handles this in code, not in the prompt. The principle is: &lt;em&gt;do work in code so the model&lt;br&gt;
doesn't spend tokens and reasoning on it.&lt;/em&gt; Here's how the self-healing path works, and the one rule that&lt;br&gt;
makes it safe.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Give elements durable ids
&lt;/h2&gt;

&lt;p&gt;Perception runs a script inside every frame and open shadow root, collects interactive elements, and&lt;br&gt;
stamps each with a durable &lt;code&gt;data-ai-id&lt;/code&gt;. Ids are never reused, even across pages, so "&lt;code&gt;e3&lt;/code&gt;" means the&lt;br&gt;
same thing five steps later. The agent acts &lt;strong&gt;by id&lt;/strong&gt;, never by guessed CSS selector or pixel&lt;br&gt;
coordinate.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Try the action, then heal only if the id vanished
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// reliableOnElement (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;reliable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;verb&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;act&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maybeApplied&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;cancelRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;healId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;newId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// refuse to guess&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reliable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;verb&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (re-located as &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;newId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;act&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newId&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Three details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One cheap retry first.&lt;/strong&gt; Most flakiness is timing, not a moved id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maybeApplied&lt;/code&gt; is never retried.&lt;/strong&gt; If a click's handler hung &lt;em&gt;after&lt;/em&gt; the event was dispatched, the
action may already have happened. Retrying could submit a form twice. The result is reported honestly
as &lt;code&gt;maybeApplied&lt;/code&gt; and the agent decides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Healing is a second phase, not the default.&lt;/strong&gt; It only runs when the first attempt failed outright.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  3. Re-locate by identity, and refuse when it's ambiguous
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;healId&lt;/code&gt; re-snapshots the page and looks for an element with the same &lt;em&gt;identity&lt;/em&gt;: tag, role and&lt;br&gt;
accessible name. It returns &lt;code&gt;null&lt;/code&gt;, meaning "I won't guess", in four cases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The id still exists.&lt;/strong&gt; Then the failure is real (covered, disabled, off-screen), not a moved id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The page is a different document.&lt;/strong&gt; After a navigation there is nothing to re-locate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The identity was ambiguous before.&lt;/strong&gt; If the previous snapshot had two "Delete" buttons, the match
is meaningless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The identity is ambiguous after.&lt;/strong&gt; If the new snapshot has zero or several matches, same problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only when exactly one element matches before &lt;em&gt;and&lt;/em&gt; after does it hand back the new id.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;same&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prevSnap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;same&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ambiguous before&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;same&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// ambiguous after&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment in the source says it best: &lt;em&gt;acting on the wrong one is worse than failing.&lt;/em&gt; A failed action&lt;br&gt;
the agent can see and reason about is cheap. A confident click on the wrong "Delete" button is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Return a verdict, not a hope
&lt;/h2&gt;

&lt;p&gt;Every action returns an &lt;code&gt;ActionResult&lt;/code&gt;: did it work, and did the page change. That distinguishes&lt;br&gt;
&lt;strong&gt;success&lt;/strong&gt;, &lt;strong&gt;silent no-op&lt;/strong&gt; (the click landed, nothing happened) and &lt;strong&gt;failure&lt;/strong&gt;. A &lt;code&gt;MutationObserver&lt;/code&gt;&lt;br&gt;
tracks a DOM version, and a fingerprint of each interactive element's label, value and state means a&lt;br&gt;
toggled checkbox or opened menu counts as a change even when no element was added or removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building a similar layer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Make "the id moved" a &lt;em&gt;recoverable&lt;/em&gt; condition, but make recovery conservative.&lt;/li&gt;
&lt;li&gt;Treat ambiguity as a hard stop. Self-healing that guesses is a bug generator.&lt;/li&gt;
&lt;li&gt;Separate "failed before dispatch" (safe to retry) from "may have applied" (never retry).&lt;/li&gt;
&lt;li&gt;Report outcomes as data the model can use, not just exceptions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;Identity is tag + role + name, so two genuinely different elements with the same label are never&lt;br&gt;
healed (by design), and a renamed element won't be re-found. It's a young, single-user developer tool,&lt;br&gt;
not yet battle-tested across thousands of real sites.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code: &lt;a href="https://github.com/HaidarESBER/ai-browser" rel="noopener noreferrer"&gt;https://github.com/HaidarESBER/ai-browser&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>playwright</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
