<?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: Helgi Skjortnes</title>
    <description>The latest articles on DEV Community by Helgi Skjortnes (@hrolgar).</description>
    <link>https://dev.to/hrolgar</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%2F4145405%2F1950cb5e-ca98-4fe0-88e4-b6bffb337f4c.png</url>
      <title>DEV Community: Helgi Skjortnes</title>
      <link>https://dev.to/hrolgar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hrolgar"/>
    <language>en</language>
    <item>
      <title>I Wrote a Linter for Text That Sounds Like AI</title>
      <dc:creator>Helgi Skjortnes</dc:creator>
      <pubDate>Sat, 03 Oct 2026 14:06:56 +0000</pubDate>
      <link>https://dev.to/hrolgar/i-wrote-a-linter-for-text-that-sounds-like-ai-3n52</link>
      <guid>https://dev.to/hrolgar/i-wrote-a-linter-for-text-that-sounds-like-ai-3n52</guid>
      <description>&lt;p&gt;Most people can spot AI text within a sentence or two now, and once they've spotted it they stop reading properly. So I built a checker that catches the tells before anything leaves my machine, calibrated on my own writing, and it learns from the edits I make.&lt;/p&gt;

&lt;p&gt;I use AI at work like most developers do. It helps me write code, summarise long threads and get a first pass at the boring documents, like a handover note or a status update after a long week. What goes in the message is still mine. I decide what to say, what the numbers are and what I'm promising, and I read and edit everything before it goes out. The problem was the part in between. Hand a model three bullet points from my notes and ask for a status update, and it comes back sounding like a brochure.&lt;/p&gt;

&lt;p&gt;Here's a made-up example, but a close one, from three lines of notes:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frkmlr5ohmz81vexnjx6z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frkmlr5ohmz81vexnjx6z.png" alt="Annotated example. Notes: export to next milestone, rest on track, done Thursday probably. Model draft (41 words): " width="800" height="668"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing in the first version is wrong. It's just that no person writes like that to someone they work with, and the reader can tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Word lists only get you so far
&lt;/h2&gt;

&lt;p&gt;Everyone knows the word list by now. The long dashes, the curly quotes, the handful of words nobody used before 2023, the "hope this helps" at the end. A regex catches those, and mine blocks them outright.&lt;/p&gt;

&lt;p&gt;The harder tells are in the structure, and these took most of the work. Things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lists of three where two would do ("focused, predictable and on schedule").&lt;/li&gt;
&lt;li&gt;"It isn't X, it's Y" as a way to sound decisive.&lt;/li&gt;
&lt;li&gt;An opener that restates the question and a closer that sums up what you just said.&lt;/li&gt;
&lt;li&gt;Every paragraph opening by announcing its topic, so the text reads like an outline with prose poured in.&lt;/li&gt;
&lt;li&gt;Paragraphs all roughly the same size, and sentences all 15 to 25 words long.&lt;/li&gt;
&lt;li&gt;A hedge on every line, even on things that were checked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are wrong on their own. I do some of them myself. That turned out to be the interesting problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calibrating against my own writing
&lt;/h2&gt;

&lt;p&gt;The first version flagged everything, including me. So I took 163 messages I'd actually sent and made them a test. Any structural check that fires on more than a quarter of my own messages fails the test suite, because at that point it's measuring how I write, not how a model writes.&lt;/p&gt;

&lt;p&gt;That killed two checks I was sure about. I'd assumed people hedge more than models do, and ask more questions back, so one check flagged a longer message with no "I think" anywhere in it and another flagged one with no question in it. On my own messages over a hundred words, the hedge check fired 80% of the time. When I know something, I mostly just say it. Both checks went.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvmwyerp8nn7sndm8z99y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvmwyerp8nn7sndm8z99y.png" alt="Bar chart of how often each structural check flagged 163 of my own sent messages, with a 25% cut-off line. Cut: no hedge anywhere 36%, no question back 34%. Kept: much longer than the question 20%, paragraphs all the same size 4%, long for a chat reply 3%, assistant opener 1%, pseudo-wisdom 1%." width="800" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What stayed is the opposite one, a hedge on nearly every line, which reads just as fake. The rule I write by now is simple. A fact I've actually checked gets stated plainly. A guess gets a personal hedge, the kind a person uses, not "it may be the case that". And if I don't know, I ask a question instead of writing a caveat.&lt;/p&gt;

&lt;p&gt;The length check stayed, even though it still fires on about one message in five. I went through those, and they were long replies to short questions that should have been shorter, sent by me or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  One door in, one door out
&lt;/h2&gt;

&lt;p&gt;Every draft goes through one script before I see it. Hard tells block, soft ones come back as warnings I have to deal with. There's also a check at the send step for each channel, so nothing gets out by a side route.&lt;/p&gt;

&lt;p&gt;That second check turned up a bug straight away. The hook was registered for two of the channels, but the script's case statement had no branch for them, so it let everything through without checking. The configuration said "covered" and the code said "skip". Those two channels were where the stiffest text had come from. It was a one-line fix, and I'd never have found it by looking at the config.&lt;/p&gt;

&lt;h2&gt;
  
  
  It learns from my edits
&lt;/h2&gt;

&lt;p&gt;When I rewrite a draft before sending it, or throw it out because it sounds like a machine, the before and after get logged. Once a night a job reads the day's pairs and writes short style rules from them, things like "when answering feedback, reply only to the points raised and skip the warm sign-off" or "end on a concrete question, not a soft line". The rules load into the next draft automatically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiq7oplrn9snqyzhdsw5s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiq7oplrn9snqyzhdsw5s.png" alt="Flow diagram: my notes, first draft from a model, linter, me (read, rewrite, decide what goes), send check, out. My edits go to a log, a nightly job turns them into style rules (max 6 a night, 25 in total), and the rules are loaded into the next draft." width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I kept that loop on a short leash. It can add at most six rules a night and keep 25 in total, the rules can only be about style and never about what needs my approval, and the file is plain text I can edit or delete from whenever I like. A model writing its own instructions is a good way to get a mess, so it gets a small box to work in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;It doesn't decide what to say. It only cleans up how something is said. Knowing what the other person actually asked, what I can promise, and when one line is the right answer is still my job, and I don't think a tool should do that part.&lt;/p&gt;

&lt;p&gt;If your team uses AI to draft support replies, sales emails or reports, and customers have started to notice, this approach carries over. Your own sent messages are the dataset, and the tells are measurable. That's the kind of tooling I like building.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally posted on &lt;a href="https://hrolgar.com/blog/a-linter-for-text-that-sounds-like-ai?utm_source=dev.to&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=a-linter-for-text-that-sounds-like-ai"&gt;hrolgar.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>writing</category>
      <category>developertools</category>
    </item>
    <item>
      <title>Setting Firefox Bookmark Keywords From an Extension, Without an API for It</title>
      <dc:creator>Helgi Skjortnes</dc:creator>
      <pubDate>Sun, 27 Sep 2026 11:18:59 +0000</pubDate>
      <link>https://dev.to/hrolgar/setting-firefox-bookmark-keywords-from-an-extension-without-an-api-for-it-4m43</link>
      <guid>https://dev.to/hrolgar/setting-firefox-bookmark-keywords-from-an-extension-without-an-api-for-it-4m43</guid>
      <description>&lt;p&gt;I use bookmark keywords all day. Type "jira" in the address bar, hit enter, and you're on the board. Firefox gives a keyword the top "Visit" suggestion in the URL bar, so it's faster than any search or history match, and it syncs to every machine you're signed in on.&lt;/p&gt;

&lt;p&gt;When I wrote &lt;a href="https://addons.mozilla.org/firefox/addon/passport-containers/?utm_source=dev.to&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=passport-containers"&gt;Passport Containers&lt;/a&gt;, a Firefox add-on for per-path container rules, I wanted to set the keyword right from the popup. Make a rule for the site, bookmark it, give it a keyword, done. The first two parts were easy. The keyword turned out to be the whole project.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is no API
&lt;/h2&gt;

&lt;p&gt;The WebExtensions bookmarks API gives you title, URL and parent folder. That's it. Keywords, tags and descriptions were all left out when the API was built, and the request to &lt;a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1276817" rel="noopener noreferrer"&gt;support bookmark keywords&lt;/a&gt; was filed in 2016. It got folded into a wider discussion about search and never came back out. I don't think it's coming.&lt;/p&gt;

&lt;p&gt;My first version faked it. A bare word in the address bar turns into a search on your default engine, so Passport watched for search requests, and when the query was one of your keywords it cancelled the search and opened the page instead. That works, and it's still what happens if you don't sign in. But you don't get the "Visit" suggestion, you get "Search with Google", and your keywords live in the extension instead of in Firefox. It felt like a workaround, because it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firefox Sync already knows how
&lt;/h2&gt;

&lt;p&gt;Firefox Sync stores every bookmark as a record on Mozilla's server, and the record has a keyword field. When Firefox pulls a bookmark record down from the server, it applies whatever keyword is in it, the same as if you'd typed it in the bookmark dialog. So if something writes the keyword into the synced record, Firefox sets a real keyword on its own, on every device.&lt;/p&gt;

&lt;p&gt;An extension can talk to Sync the same way any third-party Sync client does. It just needs the keys. That's where it gets awkward, because Sync is end-to-end encrypted with a key derived from your account password, and an extension should never see your password.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signing in without a password
&lt;/h2&gt;

&lt;p&gt;Mozilla accounts support OAuth with scoped keys. You open accounts.firefox.com, log in there, and the extension gets back an authorization code plus the Sync key, encrypted to a key pair the extension generated for that one login. Passport never sees the password, only the result.&lt;/p&gt;

&lt;p&gt;It's a normal authorization-code flow with PKCE, with one extra parameter, &lt;code&gt;keys_jwk&lt;/code&gt;, which is the public half of that key pair:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ECDH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;namedCurve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deriveBits&lt;/span&gt;&lt;span class="dl"&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;pub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jwk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publicKey&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;keysJwk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;b64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;crv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://identity.mozilla.com/apps/oldsync&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;code_challenge_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;S256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;code_challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;access_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;keys_jwk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;keysJwk&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;When you exchange the code, the token response comes back with a &lt;code&gt;keys_jwe&lt;/code&gt; next to the access token. That's a JWE with ECDH-ES and AES-256-GCM, and after decrypting it with the private key you have the 64-byte Sync key, split into an encryption key and an HMAC key. I did the decryption by hand with WebCrypto, following RFC 7518. It's about 20 lines, but Concat KDF is the kind of thing where you get every byte right or nothing works, and there's no helpful error message along the way.&lt;/p&gt;

&lt;p&gt;There's one catch I should be upfront about. Mozilla doesn't give out OAuth client ids for Sync to third parties, so Passport uses the public client id of an old Mozilla app. Its redirect address points at a page that no longer exists, so Passport catches the redirect request, takes the code out of the URL and cancels the navigation before it goes anywhere. The Sync key stays in local extension storage on that machine and never leaves it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the keyword
&lt;/h2&gt;

&lt;p&gt;With the key in hand, the rest is the regular Sync 1.5 protocol. Swap the OAuth token for storage credentials at Mozilla's token server, fetch the collection keys from &lt;code&gt;crypto/keys&lt;/code&gt;, then read the bookmark record, decrypt it, set the keyword, encrypt it again and put it back:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rec&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;getRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hawk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bookmarks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bookmarkId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;putRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hawk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bookmarks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bookmarkId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keyword&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modified&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;rec.modified&lt;/code&gt; at the end becomes an &lt;code&gt;X-If-Unmodified-Since&lt;/code&gt; header, so if Firefox changed the bookmark in the meantime the write fails instead of overwriting it. The bookmark id in the extension API is the same guid Sync uses as the record id, which saved me a lot of mapping. Only the root folders have different names on each side.&lt;/p&gt;

&lt;p&gt;A freshly created bookmark isn't on the server yet, because Firefox uploads it a few seconds later. Passport waits and retries a couple of times, and if the record still isn't there it writes the whole record itself and adds it to the parent folder's child list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Firefox to notice
&lt;/h2&gt;

&lt;p&gt;At this point the keyword is on the server, but Firefox only picks it up on its next sync, which can take a while. I didn't want "wait up to ten minutes" in the UI.&lt;/p&gt;

&lt;p&gt;Firefox syncs right away once enough has changed locally. It keeps a score, every bookmark change adds to it, and past a threshold it triggers a sync immediately. On a single-device account that's 1000 points, and a bookmark change is worth 301. So Passport keeps a small helper bookmark called "Passport sync" and edits its title four times in a row, adding and removing an invisible character. That's 1204 points, Firefox syncs, sees the new keyword on the server and applies it. From typing the keyword in the popup to seeing it in the address bar takes 5 to 10 seconds.&lt;/p&gt;

&lt;p&gt;It's a slightly silly trick, and I spent a while looking for a cleaner one. Nothing I tried was as reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;For me, yes. Being able to type a word anywhere and land in the right container on the right page is the reason I built the add-on in the first place. I also learned more about how Sync works than I ever planned to.&lt;/p&gt;

&lt;p&gt;It's also a bit fragile, and I'd rather say that here than have someone find out the hard way. It relies on a client id Mozilla could retire, and on sync scoring that could change in any release. If it breaks, the old search-catching method still works without an account, so your keywords don't disappear, you just lose the native suggestion.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/hrolgar/passport-containers" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; under MIT, and the Sync part is in &lt;code&gt;src/sync/&lt;/code&gt;. If you're building something that needs bookmark data the extension API doesn't expose, it might save you a weekend. There's more about the add-on itself on the &lt;a href="https://hrolgar.com/projects/passport-containers?utm_source=dev.to&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=passport-containers"&gt;project page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally posted on &lt;a href="https://hrolgar.com/blog/firefox-bookmark-keywords-without-an-api?utm_source=dev.to&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=passport-containers"&gt;hrolgar.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firefox</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
