<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Brent Fitzgerald</title>
  <subtitle>Helping founders turn ambitious tech ideas into something that actually runs.</subtitle>
  <link href="https://brentfitzgerald.com/" />
  <link rel="self" href="https://brentfitzgerald.com/atom.xml" />
  <id>https://brentfitzgerald.com/</id>
  <updated>2026-10-04T21:30:00Z</updated>
  <author>
    <name>Brent Fitzgerald</name>
    <uri>https://brentfitzgerald.com/</uri>
  </author>
  <entry>
    <title>How many cookies does the GPU eat?</title>
    <link href="https://brentfitzgerald.com/posts/watt-hours-in-the-terminal/" />
    <id>https://brentfitzgerald.com/posts/watt-hours-in-the-terminal/</id>
    <published>2026-10-04T21:30:00Z</published>
    <updated>2026-10-04T21:30:00Z</updated>
    <summary>If a GPU were powered by cookies, how many cookies would it need to eat just to create a PR? I set out to answer this important question by writing a Pi extension for Neuralwatt inference.</summary>
    <content type="html">&lt;p&gt;One of the inference providers in my rotation charges for measured energy instead of tokens. I wanted to see the energy numbers next to the costs, so I wrote a small &lt;a href=&#34;https://github.com/burnto/pi-neuralwatt&#34;&gt;pi-neuralwatt extension&lt;/a&gt; for my harness of choice, &lt;a href=&#34;https://pi.dev/&#34;&gt;Pi&lt;/a&gt;. I used it three months and recently looked at what it had recorded.&lt;/p&gt;
&lt;p&gt;And I also overdid it, as usual. So we now have a live measure of whatever unit we want. We can answer the question we&amp;rsquo;ve all been too afraid to ask: &lt;em&gt;How many cookies would need to be eaten by a fictional cookie-powered GPU to run our little agentic coding sessions.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Whether you find this dumb or fun depends on your definitions of those words.&lt;/p&gt;
&lt;figure class=&#34;space-y-3&#34;&gt;
  &lt;img src=&#34;https://brentfitzgerald.com/posts/watt-hours-in-the-terminal/streaming-cookies.gif&#34; alt=&#34;Pi coding agent on a Neuralwatt model editing code and running tests. After each response, the status line steps up from 31.20 Wh to 39.88 Wh, with cookies rising from 0.18 to 0.23 and brain time alongside.&#34; width=&#34;900&#34; height=&#34;665&#34; /&gt;
  &lt;figcaption class=&#34;text-fg-muted&#34;&gt;
    Chocolate chip cookie consumption
  &lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&#34;neuralwatt&#34;&gt;Neuralwatt&lt;/h2&gt;
&lt;p&gt;I have no affiliation with &lt;a href=&#34;https://neuralwatt.com/&#34;&gt;Neuralwatt&lt;/a&gt;. My interest is mostly just that I found it intriguing as a preview of a commodified inference economy where the cost of electricity dictates what we can do and afford.&lt;/p&gt;
&lt;p&gt;The pay-as-you-go rate is currently &lt;a href=&#34;https://portal.neuralwatt.com/pricing&#34;&gt;$10/kWh&lt;/a&gt;. For reference, my home electricity peaks at $0.52/kWh, which is &lt;em&gt;absurdly&lt;/em&gt; expensive compared to most of the U.S. but still only 5% of this inference rate. But I&amp;rsquo;m not really buying any old electrons. I&amp;rsquo;m paying for GPU time with an energy meter.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&#34;https://docs.neuralwatt.com/energy-methodology&#34;&gt;methodology docs&lt;/a&gt; say they read GPU power counters through &lt;a href=&#34;https://developer.nvidia.com/management-library-nvml&#34;&gt;NVML&lt;/a&gt;, which is (I learned) a library for reading stuff like power usage and utilization from NVIDIA GPUs. Then they give each request a share of the server&amp;rsquo;s measured energy based on its share of tokens in flight, up to a cap. So my energy use presumably depends partly on who/what else is using the GPUs.&lt;/p&gt;
&lt;p&gt;The pricing only counts (as far as I can tell) GPU inference. No cooling, host CPU, or networking. So the numbers are a floor. When Google &lt;a href=&#34;https://arxiv.org/abs/2508.15734&#34;&gt;measured the full picture&lt;/a&gt; for a Gemini prompt, the AI chips themselves were only a bit over half the total. The rest was the supporting computers, spare machines, and cooling. And obviously the energy costs of training the models are not reflected here.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m no expert in data center energy, so I&amp;rsquo;m trusting Neuralwatt&amp;rsquo;s numbers to be accurate-ish. And I&amp;rsquo;m not making any big decisions based on this stuff. Just little decisions. About cookies.&lt;/p&gt;
&lt;h2 id=&#34;three-months-of-records&#34;&gt;Three months of records&lt;/h2&gt;
&lt;p&gt;The extension records every Neuralwatt response. Earlier versions didn&amp;rsquo;t track everything perfectly, so the older records are rough, but I had the energy numbers. Between July and early October I had over 5,500 responses. It was mostly deepseek and glm coding.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A typical response used about 0.2 Wh.&lt;/strong&gt; This is about a minute of a 10 W LED bulb.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A typical real working session&lt;/strong&gt; (say, enough back and forth to land a PR) &lt;strong&gt;used about 12 Wh&lt;/strong&gt;, or 12 cents at pay-as-you-go rates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My biggest feature used about 250 Wh.&lt;/strong&gt; It involved building a whole revision management system with a novel interface, auth changes, etc.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of it together came to about 3 kWh&lt;/strong&gt;. That&amp;rsquo;s probably 10-12 miles in an electric car I guess? I don&amp;rsquo;t have an electric car, but it would fully discharge my RAV4 hybrid&amp;rsquo;s little battery twice.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;0.2 Wh is right in the range of published estimates for a single chatbot prompt (Google says &lt;a href=&#34;https://arxiv.org/abs/2508.15734&#34;&gt;0.24 Wh&lt;/a&gt; for a median Gemini text prompt, all-in). I&amp;rsquo;d assumed agentic coding would be much worse, since every turn resends the whole conversation. In my sessions the median context was about 70,000 tokens. So caching is where the efficiency comes from. About 98% of my input tokens were cache reads, which are cheap. Agents mostly read a lot and write a little. An output token cost something like 50 times as much energy as a new input token.&lt;/p&gt;
&lt;p&gt;Still, the context costs add up. Energy per response grew as sessions went on. The first few turns of a session used about 0.06 Wh each. After turn 50 it was closer to 0.3 Wh. Another reason to start fresh sessions often.&lt;/p&gt;
&lt;h2 id=&#34;cookie-costs&#34;&gt;Cookie costs&lt;/h2&gt;
&lt;p&gt;But you&amp;rsquo;re not here for the watts per hour. You&amp;rsquo;re here for the cookies.&lt;/p&gt;
&lt;p&gt;This extention shows everyday equivalents next to the energy number. You pick them in &lt;code&gt;/neuralwatt:settings&lt;/code&gt;, or even add new ones. Some of the defaults are power draws (a 20 W human brain, a 10 W LED bulb) and show how long that thing could run. Others are units (calories, cookies) and show a quantity.&lt;/p&gt;
&lt;figure class=&#34;space-y-3&#34;&gt;
  &lt;img src=&#34;https://brentfitzgerald.com/posts/watt-hours-in-the-terminal/equivalents.gif&#34; alt=&#34;The /neuralwatt:settings menu. Brain, LED bulb, food calorie, and cookie equivalents are switched on one by one and the status line grows to show each, then the indicator color changes to violet.&#34; width=&#34;900&#34; height=&#34;537&#34; /&gt;
  &lt;figcaption class=&#34;text-fg-muted&#34;&gt;
    Choose your units wisely.
  &lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The default cookie option assumes one chocolate chip cookie is 150 calories, about 174 Wh. It&amp;rsquo;s editable if you prefer very large or small cookies. My sized cookie of GPU energy costs about $1.74 at pay-as-you-go rates. Cheaper than local bakery cookies!&lt;/p&gt;
&lt;p&gt;So, at least for me, a normal PR cost about 7% of a cookie. My entire three months cost 17 cookies. That is less than I&amp;rsquo;ve actually eaten in the same period.&lt;/p&gt;
&lt;p&gt;Of course this is one person using some smallish open models. It says nothing about training, or the data centers going up, or everyone else&amp;rsquo;s requests added together. Maybe the frontier lab secret models eat hundreds of cookies per hour? &lt;em&gt;We&amp;rsquo;ll never know.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&#34;brainpower&#34;&gt;Brainpower&lt;/h2&gt;
&lt;p&gt;The brain preset turned out to be another fun one. A brain runs on about 20 W of metabolic power, so 1 Wh is roughly 3 minutes of brain.&lt;/p&gt;
&lt;p&gt;My typical 12 Wh session works out to about 35 minutes of brain. Which is how long I&amp;rsquo;d expect a person to spend focusing on a small task like that. A patient, tireless, focused person. My whole three months of GPU energy is about 150 hours of brain.&lt;/p&gt;
&lt;p&gt;A brain typically needs a body, a laptop, a monitor, a commute. Still, I feel it&amp;rsquo;s a useful check. For this kind of work, the energy the GPU used was not &lt;em&gt;wildly&lt;/em&gt; different than the energy of the person sitting there vibing out.&lt;/p&gt;
&lt;h2 id=&#34;so-the-extension&#34;&gt;So, the extension&lt;/h2&gt;
&lt;p&gt;I first threw together a version of this extension a few months ago, and there were already two others. A third popped up at some point as well.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/aliou/pi-neuralwatt&#34;&gt;aliou/pi-neuralwatt&lt;/a&gt; pairs the provider with a tabbed &lt;code&gt;/neuralwatt:quota&lt;/code&gt; command, low-quota warnings, and status-bar usage. This is what I initially used.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/tedewaard/pi-neuralwatt&#34;&gt;tedewaard/pi-neuralwatt&lt;/a&gt; adds energy and quota reports and a toggleable status-bar widget.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/monotykamary/pi-neuralwatt-provider&#34;&gt;monotykamary/pi-neuralwatt-provider&lt;/a&gt; is more recent and has a lot, including provider setup, an energy and cost widget below the editor, quota display, flex and fast variants, and hosted-tool support.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Mine isn&amp;rsquo;t remarkably different! My main focus was the per-response energy telemetry Neuralwatt adds to the response stream. Alongside the usual SSE &lt;code&gt;data:&lt;/code&gt; chunks, there are comment lines: &lt;code&gt;: energy {...}&lt;/code&gt; and &lt;code&gt;: cost {...}&lt;/code&gt;. The cost is the reported request cost in dollars, and the energy is reported GPU energy in kWh. A typical client parses the data events and never sees the comments. So like a few of the others, this extension tees the response body and reads the comment lines.&lt;/p&gt;
&lt;h3 id=&#34;tree-vs-session&#34;&gt;Tree vs session&lt;/h3&gt;
&lt;p&gt;One thing I hadn&amp;rsquo;t thought much about prior to digging in: Pi&amp;rsquo;s footer sums cost across the whole append-only session file. But a conversation is a tree. Rewind a few responses or fork the thread, and the abandoned branch is still in that file.&lt;/p&gt;
&lt;p&gt;The extension has its own totals, rebuilt from the active branch, so a rewind does not include the abandoned turns. The footer and the &lt;code&gt;⚡️&lt;/code&gt; indicator can disagree. When they do, the indicator is showing the branch you&amp;rsquo;re actually on.&lt;/p&gt;
&lt;h3 id=&#34;extension-hygiene&#34;&gt;Extension hygiene&lt;/h3&gt;
&lt;p&gt;A few choices to keep the extension from being too invasive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Settings live in a plain JSON file, and auth goes through Pi&amp;rsquo;s normal credential handling rather than a custom store.&lt;/li&gt;
&lt;li&gt;Stream interception is scoped to the extension&amp;rsquo;s own requests, so global &lt;code&gt;fetch&lt;/code&gt; is never patched and other providers are unaffected.&lt;/li&gt;
&lt;li&gt;The records are custom session entries that never enter model context.&lt;/li&gt;
&lt;li&gt;Cost patching uses Pi&amp;rsquo;s public &lt;code&gt;message_end&lt;/code&gt; replacement and leaves the footer in place.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It counts responses it actually saw, so retries, background compaction, and anything elsewhere may be missing. The &lt;a href=&#34;https://github.com/burnto/pi-neuralwatt#limitations&#34;&gt;README&lt;/a&gt; has details.&lt;/p&gt;
&lt;h2 id=&#34;what-other-kinds-of-costs-could-we-measure&#34;&gt;What other kinds of costs could we measure?&lt;/h2&gt;
&lt;p&gt;While I don&amp;rsquo;t have a stake in energy pricing specifically, I appreciate seeing what our AI future costs in units other than plain old money.&lt;/p&gt;
&lt;p&gt;Neuralwatt was already calculating &lt;a href=&#34;https://docs.neuralwatt.com/energy-methodology#carbon-emissions-tracking&#34;&gt;CO2 emissions equivalents&lt;/a&gt; based on location, via &lt;a href=&#34;https://www.electricitymaps.com/&#34;&gt;Electricity Maps&lt;/a&gt;. Energy turns out to be a pretty good common unit for reasoning about real world costs. I wonder what else we could convert it into to better show the externalities?&lt;/p&gt;
&lt;p&gt;And I can&amp;rsquo;t help but ponder measuring other costs we associate with this technology, like cumulative hours of intellectual labor that went into the response.&lt;/p&gt;
&lt;p&gt;But I&amp;rsquo;ve written enough about my little extension and inference costs. Perhaps I deserve a cookie.&lt;/p&gt;
&lt;p&gt;Actually, if I were to pay back those three months by pedaling a generator, with &lt;a href=&#34;https://pubmed.ncbi.nlm.nih.gov/1501563/&#34;&gt;human muscles at roughly 22% efficiency&lt;/a&gt;, I&amp;rsquo;d need to eat 77 cookies.&lt;/p&gt;
&lt;h2 id=&#34;links&#34;&gt;Links&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/burnto/pi-neuralwatt&#34;&gt;github&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.npmjs.com/package/@burnto/pi-neuralwatt&#34;&gt;npm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://pi.dev/packages/@burnto/pi-neuralwatt&#34;&gt;pi package&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <title>The human is the loop</title>
    <link href="https://brentfitzgerald.com/posts/the-human-is-the-loop/" />
    <id>https://brentfitzgerald.com/posts/the-human-is-the-loop/</id>
    <published>2026-08-12T00:30:00Z</published>
    <updated>2026-08-12T00:30:00Z</updated>
    <summary>After a few weeks away from AI, I see my usage wasn&#39;t always productive or healthy.</summary>
    <content type="html">&lt;p&gt;I recently stepped away from AI for a few weeks. Coming back, I&amp;rsquo;m more aware of the unhealthy habits I&amp;rsquo;d formed using it.&lt;/p&gt;
&lt;p&gt;It was an end-of-summer vacation, and I was lucky to be swimming in cold rivers, eating one too many s&amp;rsquo;mores, napping after lunch, etc. There was a lot of indulgence and a break from work and routines. For much of the time it was a break from my laptop and phone altogether, which meant a few weeks without AI.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t miss it. What I&amp;rsquo;ve realized these past few days back is how much of my AI use was unnecessary. In fact, I think it&amp;rsquo;s often been a habit-forming crutch making me intellectually weaker, less curious, and less confident. It&amp;rsquo;s probably made me a little depressed too.&lt;/p&gt;
&lt;p&gt;When I eventually opened my laptop a few days ago, I was greeted with eleven cmux tabs, each with multiple agents paused midway through various efforts. Some professional, some personal, and a lot in between. I also had unread badges on Claude chats across everything from taxes to landscaping ideas to policy doc review.&lt;/p&gt;
&lt;p&gt;Is this the 2026 version of having a bunch of tabs open? Maybe, but it feels way worse. Each of these was an initiative I thought I needed help with, or that I wanted to automate or outsource some thinking on. But each is also a little stub of guilt over never finishing things, over not being efficient or smart or focused enough with my time.&lt;/p&gt;
&lt;p&gt;Obviously I&amp;rsquo;m being hard on myself. That&amp;rsquo;s not new behavior, and it&amp;rsquo;s long been a source of pressure based on unrealistic expectations. I&amp;rsquo;ve managed that stress and self pressure with mixed success in the past. But in this machine intelligence era, the pressure has found new outlets. Instead of forcing me to triage and focus on what matters most in work and life, it feeds an underexamined belief that I should be able to do more now. And it finds release in yet another agent conversation, another terminal pane, another fork no one asked for, another bullshit markdown output I&amp;rsquo;ll never read.&lt;/p&gt;
&lt;p&gt;I have so many half-baked ideas for tools or services I barely need, sparked by a combo of unaddressed workaholism and techno-optimist wishful thinking. And sure, the little things I&amp;rsquo;ve made do work. They&amp;rsquo;re running, doing their little things. But none of it helps anyone, and none of it makes me happier or gives me more free time. I used to &lt;a href=&#34;https://brentfitzgerald.com/posts/in-praise-of-tinkering/&#34;&gt;tinker&lt;/a&gt; on personal projects as a way of relaxing and learning for the fun of it. These projects don&amp;rsquo;t do that. I often skip the learning to get to the result, and the learning is where the joy happens.&lt;/p&gt;
&lt;p&gt;I also think I have been using agents as a layer between me and the tasks that cause me stress. Instead of just taking on the thing directly, I put an agent in the mix. It&amp;rsquo;s like a special stuffy or totem that protects me.&lt;/p&gt;
&lt;p&gt;If I needed to write something, I&amp;rsquo;d dictate all my thoughts, then talk through it with ChatGPT while driving or walking. I&amp;rsquo;d justify it as bouncing ideas around with myself, as making use of time that would otherwise not be productive. But I knew it was really a sycophantic mirror. And because it was not a realistic thought partner, I never relied on the output of those sessions for real work. So how useful were all those hours really? Now it&amp;rsquo;s just rambling recordings and transcripts on OpenAI&amp;rsquo;s servers.&lt;/p&gt;
&lt;p&gt;Another example: I&amp;rsquo;d try to set things up to be more automatic, so I could theoretically get work done faster and parallelize a bunch of different tasks at once. But in hindsight, no one was asking for these efficiency gains. Equipped with tools that could theoretically make me a faster, more capable builder, I felt a strong urge to maximize my use of the tools by applying them to the task of&amp;hellip; maximizing my use of the tools. It&amp;rsquo;s reflexive in the worst way, a productivity ouroboros.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m not arguing that this technology &lt;em&gt;necessarily&lt;/em&gt; has these effects on its users. And I think the format, design, and culture around the tech are a huge factor in the habits I formed.&lt;/p&gt;
&lt;p&gt;My hunch is that agent usage is (thankfully) not intrinsically neurochemically addictive in the same way as, say, endless algo feed scrolling. But I do see now that there are some very real dependency and habituation effects. Once you introduce AI tools into some of your work, it&amp;rsquo;s quick to see more ways you might start incorporating them. There&amp;rsquo;s also a large segment of the tech industry now betting on a mass socioeconomic dependency on LLMs. The only way those valuations are ever justified is if we collectively become hopelessly dependent on AI-based tech.&lt;/p&gt;
&lt;p&gt;So my plan going forward is simple: be intentional about when to use AI and when to leave it out, and try to be honest about what I&amp;rsquo;m actually gaining and losing with my choices.&lt;/p&gt;
&lt;p&gt;Before switching to writing this post, I did enter a prompt into &lt;a href=&#34;https://pi.dev/&#34;&gt;pi&lt;/a&gt; for a work project I&amp;rsquo;m starting. I gave it a bunch of requirements and suggestions, and told it to look at codebases, wikis, schemas, conversations. Setting it up forced me to catch up and think through the current situation. I asked it to come back with flagged issues and possible solutions. I constrained it narrowly and gave it as much context as I could, including what I was already thinking and what I wasn&amp;rsquo;t sure about. And I provided very clear expectations of output.&lt;/p&gt;
&lt;p&gt;The AI isn&amp;rsquo;t going to hand me the perfect solution. The result is not going to 10x me. But it&amp;rsquo;s pattern matching and searching across a mess of systems and SaaS products, which I&amp;rsquo;m not great at and don&amp;rsquo;t enjoy. It might reveal a few gaps in my understanding of the situation and improve the context I bring into the project.&lt;/p&gt;
&lt;p&gt;More important: it&amp;rsquo;s freeing me up to do human stuff like write and reflect right now. I&amp;rsquo;m still cautiously optimistic about this tech possibly letting people lead richer, more thoughtful lives. But that&amp;rsquo;s only possible if we use it on our terms. We don&amp;rsquo;t want the human trapped in the agent loop. The human &lt;em&gt;is&lt;/em&gt; the loop, and we tag the agent in occasionally, thoughtfully.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Appreciating Exif</title>
    <link href="https://brentfitzgerald.com/posts/appreciating-exif/" />
    <id>https://brentfitzgerald.com/posts/appreciating-exif/</id>
    <published>2026-06-09T19:00:00Z</published>
    <updated>2026-06-09T19:00:00Z</updated>
    <summary>The &#39;90s-era digital camera metadata format, still useful in 2026.</summary>
    <content type="html">&lt;p&gt;I recently was writing some code to apply a mask to an image input. The mask had no Exif metadata, but the image did, so I had to adjust for the orientation of the image by reading it from Exif.&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t hard with libraries, and I knew the gist of the problem: images can have Exif, Exif is optional, phones and cameras use it for orientation, and you need to account for that when processing pixels directly. But I did not have a clear mental model of how Exif actually is represented in files.&lt;/p&gt;
&lt;p&gt;I was curious. I had &lt;em&gt;questions&lt;/em&gt;:&lt;/p&gt;
&lt;p&gt;Where exactly is that orientation value stored? When should I rotate pixels instead of preserving a tag? What else might be hiding in the metadata? When is it typically stripped?&lt;/p&gt;
&lt;p&gt;So this is a little random walk guide to Exif.&lt;/p&gt;
&lt;h2 id=&#34;what-is-exif&#34;&gt;What is Exif?&lt;/h2&gt;
&lt;p&gt;Exif is short for &lt;em&gt;Exchangeable Image File Format&lt;/em&gt;. The current standard comes from &lt;a href=&#34;https://www.cipa.jp/e/std/std-sec.html&#34;&gt;CIPA&lt;/a&gt;, which lists it as &amp;ldquo;Exchangeable image file format for digital still cameras: Exif Version 3.1.&amp;rdquo;&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a href=&#34;#fn:1&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt; The &lt;a href=&#34;https://www.loc.gov/preservation/digital/formats/fdd/fdd000618.shtml&#34;&gt;Library of Congress has a good preservation-oriented summary&lt;/a&gt;, too. You&amp;rsquo;ll often see &lt;code&gt;EXIF&lt;/code&gt; in all caps in camera docs, file-format chunk names, and old forum posts, but &lt;code&gt;Exif&lt;/code&gt; seems to be the normal spelling in the standard itself.&lt;/p&gt;
&lt;p&gt;It is a metadata format that came out of the digital camera world in 1995, back when the problem was something like: this camera produced a JPEG, but where do we put the timestamp, shutter speed, aperture, focal length, thumbnail, and the fact that the camera was sideways?&lt;/p&gt;
&lt;p&gt;The answer was Exif.&lt;/p&gt;
&lt;p&gt;Most people run into this data through images from phones and cameras. It is also closely related to TIFF, because the actual Exif payload is a TIFF-shaped data structure living inside another file. Newer formats can carry Exif too, but each format gives it a different house.&lt;sup id=&#34;fnref:2&#34;&gt;&lt;a href=&#34;#fn:2&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s optional. An image can have none. A camera image probably has some, but a processed image may have had it stripped. A synthetic image can have fake Exif because metadata is just data someone wrote into the file.&lt;/p&gt;
&lt;h2 id=&#34;where-it-lives&#34;&gt;Where it lives&lt;/h2&gt;
&lt;p&gt;For JPEG, Exif usually lives near the beginning of the file in an &lt;code&gt;APP1&lt;/code&gt; marker segment.&lt;/p&gt;
&lt;p&gt;A JPEG starts with two bytes:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;FF D8
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That is the start-of-image marker. After that, a JPEG is a series of marker segments. Each segment starts with &lt;code&gt;FF&lt;/code&gt; and a marker byte. APP1 is:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;FF E1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If that APP1 segment contains Exif, its payload starts with:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;45 78 69 66 00 00
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;or, as text:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Exif\0\0
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then comes the TIFF-based part. It starts with a byte order marker:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;II  // Intel, little-endian
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;MM  // Motorola, big-endian
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then the TIFF magic number, &lt;code&gt;42&lt;/code&gt;, then an offset to the first Image File Directory, usually called IFD0. An IFD is a list of entries. Each entry has a tag id, a type, a count, and either a value or an offset to the value.&lt;/p&gt;
&lt;p&gt;Exif orientation is tag &lt;code&gt;0x0112&lt;/code&gt;. It is usually in IFD0. Its value is a small integer from 1 to 8.&lt;/p&gt;
&lt;p&gt;That is the whole trick at a very high level. A tool looking for Exif in a JPEG:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;walks the JPEG markers to find APP1,&lt;/li&gt;
&lt;li&gt;checks for &lt;code&gt;Exif\0\0&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;reads the TIFF header,&lt;/li&gt;
&lt;li&gt;follows the IFD entries, and&lt;/li&gt;
&lt;li&gt;looks for the tags it cares about.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the &amp;ldquo;where is Exif?&amp;rdquo; depends on the file. In JPEG, it is usually APP1. In WebP, it is an &lt;code&gt;EXIF&lt;/code&gt; chunk. In HEIC, it is inside the HEIF box structure. If you want a lovely older walkthrough of the JPEG layout, the &lt;a href=&#34;https://www.media.mit.edu/pia/Research/deepview/exif.html&#34;&gt;MIT Media Lab&amp;rsquo;s Deep View project has one&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&#34;a-boring-standard-that-aged-well&#34;&gt;A boring standard that aged well&lt;/h2&gt;
&lt;p&gt;I have a soft spot for simple standards that just keep working.&lt;/p&gt;
&lt;p&gt;Exif is not clean in the way you might design something from scratch today. It has TIFF internals. It has manufacturer MakerNotes. It has duplicate concepts across Exif, XMP, IPTC, ICC profiles, C2PA, and container metadata. The orientation tag feels simple until you try to explain values 5 and 7.&lt;/p&gt;
&lt;p&gt;It has continued to solve a real problem, though: pixels are not enough. A camera needs somewhere to put the circumstances of the image, and it&amp;rsquo;s simpler for everyone if that data is bundled into the image rather than shipped around as an accompanying file.&lt;/p&gt;
&lt;p&gt;I admire it because it grew out of its initial container. In JPEG, Exif usually sits in APP1. In newer file formats like HEIC, it lives somewhere else. But the same payload format still applies. A phone in 2026 can take a photo in a modern container and still carry metadata shaped by decisions from the digital camera era.&lt;/p&gt;
&lt;h2 id=&#34;what-exif-is-used-for&#34;&gt;What Exif is used for&lt;/h2&gt;
&lt;p&gt;The common stuff is what you would expect from a camera:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;date and time&lt;/li&gt;
&lt;li&gt;camera make and model&lt;/li&gt;
&lt;li&gt;lens model&lt;/li&gt;
&lt;li&gt;shutter speed&lt;/li&gt;
&lt;li&gt;aperture&lt;/li&gt;
&lt;li&gt;ISO&lt;/li&gt;
&lt;li&gt;focal length&lt;/li&gt;
&lt;li&gt;flash&lt;/li&gt;
&lt;li&gt;GPS location&lt;/li&gt;
&lt;li&gt;orientation&lt;/li&gt;
&lt;li&gt;software&lt;/li&gt;
&lt;li&gt;color-space hints&lt;/li&gt;
&lt;li&gt;manufacturer-specific MakerNotes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Thumbnails are a &amp;ldquo;maybe&amp;rdquo;: Exif can carry an embedded thumbnail, usually in IFD1.&lt;sup id=&#34;fnref:3&#34;&gt;&lt;a href=&#34;#fn:3&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;3&lt;/a&gt;&lt;/sup&gt; It commonly is a small embedded thumbnail. Larger previews are messier. Some are Exif thumbnails, some are MakerNotes, some are MPF data, and some live in container-specific metadata.&lt;/p&gt;
&lt;p&gt;This is not everything. It is the usual useful slice. Photo apps use this data to sort, search, display, group, and edit images. Websites and upload pipelines use it, sometimes accidentally, to rotate images correctly. Photographers use it to inspect how a shot was made. Asset-management systems use it alongside other metadata standards for rights, captions, credits, and workflow state.&lt;/p&gt;
&lt;p&gt;Color is a good example of boundaries blurring. Exif has a &lt;code&gt;ColorSpace&lt;/code&gt; tag, but full &lt;a href=&#34;https://www.color.org/specification/ICC.1-2022-05.pdf&#34;&gt;ICC color profiles&lt;/a&gt; are a separate kind of metadata.&lt;sup id=&#34;fnref:4&#34;&gt;&lt;a href=&#34;#fn:4&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;4&lt;/a&gt;&lt;/sup&gt; If an image changed size, rotated, lost its color, or started displaying differently after a pipeline step, metadata is one of the first places I would look, but I would not assume the answer is specifically Exif.&lt;/p&gt;
&lt;p&gt;And of course, metadata is just whatever was put in the file. A file can say it came from a camera it did not come from. Timestamp can be wrong, GPS can be fake, a string field can contain gobblygook.&lt;/p&gt;
&lt;h2 id=&#34;use-exiftool-first&#34;&gt;Use exiftool first&lt;/h2&gt;
&lt;p&gt;If you are doing anything technical with image metadata, start with &lt;a href=&#34;https://exiftool.org/&#34;&gt;&lt;code&gt;exiftool&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It is Perl and is old in the very good way. It has baked into it all sorts of knowledge about metadata weirdnesses that exist in real files.&lt;/p&gt;
&lt;p&gt;The basic command is:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here is the top of a real iPhone JPEG:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ExifTool Version Number         : 13.55
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;File Name                       : image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Directory                       : /tmp
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;File Size                       : 3.6 MB
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;File Type                       : JPEG
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;File Type Extension             : jpg
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;MIME Type                       : image/jpeg
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;JFIF Version                    : 1.01
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Exif Byte Order                 : Big-endian (Motorola, MM)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Make                            : Apple
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Camera Model Name               : iPhone 13
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Orientation                     : Rotate 90 CW
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;X Resolution                    : 72
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Y Resolution                    : 72
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Resolution Unit                 : inches
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Software                        : 26.3.1
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Modify Date                     : 2026:04:21 20:05:42
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Host Computer                   : iPhone 13
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Exposure Time                   : 1/317
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;F Number                        : 1.6
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Exposure Program                : Program AE
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ISO                             : 50
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Exif Version                    : 0232
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Date/Time Original              : 2026:04:21 20:05:42
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Create Date                     : 2026:04:21 20:05:42
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And that is just the start. The full output keeps going: MakerNotes, GPS, an embedded thumbnail, MPF data, and an ICC profile.&lt;/p&gt;
&lt;p&gt;For debugging, it&amp;rsquo;s nice to have groups and raw-ish tag names:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool -a -G1 -s image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An excerpt from the same file looks like this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[File]          ExifByteOrder                   : Big-endian (Motorola, MM)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[File]          ImageWidth                      : 4032
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[File]          ImageHeight                     : 3024
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[JFIF]          XResolution                     : 300
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[JFIF]          YResolution                     : 300
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[IFD0]          Make                            : Apple
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[IFD0]          Model                           : iPhone 13
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[IFD0]          Orientation                     : Rotate 90 CW
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[IFD0]          XResolution                     : 72
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[IFD0]          YResolution                     : 72
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[ExifIFD]       ExposureTime                    : 1/317
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[ExifIFD]       FNumber                         : 1.6
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[ExifIFD]       ISO                             : 50
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[ExifIFD]       DateTimeOriginal                : 2026:04:21 20:05:42
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[ExifIFD]       FocalLength                     : 5.1 mm
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[ExifIFD]       LensModel                       : iPhone 13 back dual wide camera 5.1mm f/1.6
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[Apple]         MakerNoteVersion                : 16
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[Apple]         AccelerationVector              : -0.0088618109 -0.1003010348 -1.003354311
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[GPS]           GPSLatitude                     : [redacted, because this is the internet]
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[GPS]           GPSLongitude                    : [redacted, same reason]
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[GPS]           GPSHPositioningError            : 20.61573126 m
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[IFD1]          ThumbnailLength                 : 6400
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[MPF0]          NumberOfImages                  : 2
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[ICC_Profile]   ProfileDescription              : Display P3
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is why I like the grouped view. &lt;code&gt;Orientation&lt;/code&gt; is in &lt;code&gt;IFD0&lt;/code&gt;. Exposure details are in &lt;code&gt;ExifIFD&lt;/code&gt;. Apple-specific camera data is in &lt;code&gt;Apple&lt;/code&gt;. The thumbnail is in &lt;code&gt;IFD1&lt;/code&gt;. The color profile is not Exif at all. And yes, GPS can be right there in the file.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;-a&lt;/code&gt; shows duplicate tags. &lt;code&gt;-G1&lt;/code&gt; shows the family/group. &lt;code&gt;-s&lt;/code&gt; uses short tag names. This matters because &amp;ldquo;the orientation&amp;rdquo; or &amp;ldquo;the date&amp;rdquo; may exist in more than one place.&lt;/p&gt;
&lt;p&gt;For orientation specifically:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool -Orientation image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool -Orientation# image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first gives the friendly value:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Orientation            : Rotate 90 CW
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The second gives the raw integer:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Orientation            : 6
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To strip metadata:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool -all&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;By default, &lt;code&gt;exiftool&lt;/code&gt; writes a backup file ending in &lt;code&gt;_original&lt;/code&gt;. This is slightly annoying when the files clutter your directory, but charming once you remember that it is politely not overwriting.&lt;/p&gt;
&lt;p&gt;If you do not want the backup:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool -overwrite_original -all&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id=&#34;orientation&#34;&gt;Orientation&lt;/h2&gt;
&lt;p&gt;Orientation is what I typically encounter with Exif.&lt;/p&gt;
&lt;p&gt;Cameras and phones do not typically rotate the pixel matrix when you turn the device. They save the pixels in a native orientation and write an Exif tag that tells viewers how to display those pixels.&lt;/p&gt;
&lt;p&gt;Most image viewers respect the tag. Some image-processing tools expose the raw pixels first. Some preserve the tag, others clear the tag. All of these behaviors are defensible in some situations. Depends on the context.&lt;/p&gt;
&lt;p&gt;Here are the eight Exif orientation values. These are JPEGs generated for this post. They all start from the same 160×160 base image data, then get a different Exif orientation value written with &lt;code&gt;exiftool&lt;/code&gt;.&lt;/p&gt;
&lt;table class=&#34;exif-orientation-table&#34;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Value&lt;/th&gt;
      &lt;th&gt;Image&lt;/th&gt;
      &lt;th&gt;Exif meaning&lt;/th&gt;
      &lt;th&gt;Display correction&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td class=&#34;exif-orientation-image&#34;&gt;&lt;span&gt;&lt;img src=&#34;https://brentfitzgerald.com/posts/appreciating-exif/orientation-1.jpg&#34; alt=&#34;Orientation 1 sample image&#34; /&gt;&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;top-left&lt;/td&gt;
      &lt;td&gt;normal&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td class=&#34;exif-orientation-image&#34;&gt;&lt;span&gt;&lt;img src=&#34;https://brentfitzgerald.com/posts/appreciating-exif/orientation-2.jpg&#34; alt=&#34;Orientation 2 sample image&#34; /&gt;&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;top-right&lt;/td&gt;
      &lt;td&gt;mirror horizontal&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3&lt;/td&gt;
      &lt;td class=&#34;exif-orientation-image&#34;&gt;&lt;span&gt;&lt;img src=&#34;https://brentfitzgerald.com/posts/appreciating-exif/orientation-3.jpg&#34; alt=&#34;Orientation 3 sample image&#34; /&gt;&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;bottom-right&lt;/td&gt;
      &lt;td&gt;rotate 180°&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td class=&#34;exif-orientation-image&#34;&gt;&lt;span&gt;&lt;img src=&#34;https://brentfitzgerald.com/posts/appreciating-exif/orientation-4.jpg&#34; alt=&#34;Orientation 4 sample image&#34; /&gt;&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;bottom-left&lt;/td&gt;
      &lt;td&gt;mirror vertical&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5&lt;/td&gt;
      &lt;td class=&#34;exif-orientation-image&#34;&gt;&lt;span&gt;&lt;img src=&#34;https://brentfitzgerald.com/posts/appreciating-exif/orientation-5.jpg&#34; alt=&#34;Orientation 5 sample image&#34; /&gt;&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;left-top&lt;/td&gt;
      &lt;td&gt;transpose&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;6&lt;/td&gt;
      &lt;td class=&#34;exif-orientation-image&#34;&gt;&lt;span&gt;&lt;img src=&#34;https://brentfitzgerald.com/posts/appreciating-exif/orientation-6.jpg&#34; alt=&#34;Orientation 6 sample image&#34; /&gt;&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;right-top&lt;/td&gt;
      &lt;td&gt;rotate 90° CW&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;7&lt;/td&gt;
      &lt;td class=&#34;exif-orientation-image&#34;&gt;&lt;span&gt;&lt;img src=&#34;https://brentfitzgerald.com/posts/appreciating-exif/orientation-7.jpg&#34; alt=&#34;Orientation 7 sample image&#34; /&gt;&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;right-bottom&lt;/td&gt;
      &lt;td&gt;transverse&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;8&lt;/td&gt;
      &lt;td class=&#34;exif-orientation-image&#34;&gt;&lt;span&gt;&lt;img src=&#34;https://brentfitzgerald.com/posts/appreciating-exif/orientation-8.jpg&#34; alt=&#34;Orientation 8 sample image&#34; /&gt;&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;left-bottom&lt;/td&gt;
      &lt;td&gt;rotate 270° CW / 90° CCW&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If your browser respects Exif orientation, those should display differently. If you right-click and save them, you can inspect the raw tags yourself:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool -Orientation -Orientation# orientation-6.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The files were made roughly like this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;cp base.jpg orientation-6.jpg
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool -overwrite_original -Orientation#&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;m&#34;&gt;6&lt;/span&gt; orientation-6.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As a developer, if you are doing spatial transformations, it&amp;rsquo;s simplest if you normalize the orientation before touching pixels. After that, set orientation to 1 or remove the tag. Otherwise, some later viewer may helpfully rotate your already-rotated image again.&lt;/p&gt;
&lt;p&gt;For example, if you were using Python + PIL, you could call &lt;a href=&#34;https://pillow.readthedocs.io/en/stable/reference/ImageOps.html#PIL.ImageOps.exif_transpose&#34;&gt;&lt;code&gt;ImageOps.exif_transpose(img)&lt;/code&gt;&lt;/a&gt;. In JavaScript/TypeScript with Sharp, calling &lt;a href=&#34;https://sharp.pixelplumbing.com/api-operation/#rotate&#34;&gt;&lt;code&gt;.rotate()&lt;/code&gt; with no angle&lt;/a&gt; auto-orients based on Exif orientation.&lt;/p&gt;
&lt;p&gt;In other stacks the method name may be &lt;code&gt;autorotate&lt;/code&gt;, &lt;code&gt;autoOrient&lt;/code&gt;, or a flag on decode. The name matters less than knowing whether the pixels you are working with are raw or display-oriented.&lt;/p&gt;
&lt;h2 id=&#34;dumb-exif-tricks&#34;&gt;Dumb Exif tricks&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;re an &lt;a href=&#34;https://openai.com/index/where-the-goblins-came-from/&#34;&gt;LLM obsessed with goblins&lt;/a&gt;, you can put a goblin in the artist field:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool -Artist&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;a small goblin&amp;#39;&lt;/span&gt; image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You can put GPS coordinates on a totally synthetic image:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool &lt;span class=&#34;se&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  -GPSLatitude&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;37.7749 -GPSLatitudeRef&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;N &lt;span class=&#34;se&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  -GPSLongitude&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;122.4194 -GPSLongitudeRef&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;W &lt;span class=&#34;se&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You can make an image claim to come from whatever camera you want:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;exiftool -Make&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;Definitely A Camera&amp;#39;&lt;/span&gt; -Model&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;Trust Me 9000&amp;#39;&lt;/span&gt; image.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is funny, but it is also the point. Metadata is not a sworn affidavit. It is input. Sometimes useful input. Sometimes private input. Sometimes nonsense input.&lt;/p&gt;
&lt;h2 id=&#34;other-metadata-besides-exif&#34;&gt;Other metadata besides Exif&lt;/h2&gt;
&lt;p&gt;Exif is only one part of the image metadata pile.&lt;/p&gt;
&lt;p&gt;There is &lt;a href=&#34;https://developer.adobe.com/xmp/docs/XMPSpecifications/&#34;&gt;XMP&lt;/a&gt;, which is the RDF/XML-style format used by Adobe and a lot of asset workflows. It can carry edits, ratings, rights, captions, application state, and custom namespaces.&lt;/p&gt;
&lt;p&gt;There is &lt;a href=&#34;https://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata&#34;&gt;IPTC Photo Metadata&lt;/a&gt;, which comes from news and photojournalism workflows: captions, credits, copyright, keywords, location, and editorial fields.&lt;/p&gt;
&lt;p&gt;There are ICC profiles, which are not Exif but matter a lot if you care about color. If you have ever had an image look washed out or weirdly saturated after conversion, color profiles may have been involved.&lt;/p&gt;
&lt;p&gt;There is also &lt;a href=&#34;https://c2pa.org/specifications/specifications/&#34;&gt;C2PA&lt;/a&gt;, the Content Credentials/provenance world. C2PA is for signed claims about where a piece of media came from and what happened to it. It can include assertions, thumbnails, ingredients, hashes, and signatures. &lt;a href=&#34;https://openai.com/index/advancing-content-provenance/&#34;&gt;OpenAI describes using C2PA metadata for generated images&lt;/a&gt;, and other image-generation and creative tools are moving in the same direction.&lt;/p&gt;
&lt;p&gt;C2PA is not Exif. It is image metadata, but it is not the old TIFF-shaped Exif payload in APP1.&lt;sup id=&#34;fnref:5&#34;&gt;&lt;a href=&#34;#fn:5&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;5&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;There are also structures specific to image formats. JPEGs can have &lt;code&gt;JFIF&lt;/code&gt; metadata. PNGs have chunks like &lt;code&gt;tEXt&lt;/code&gt;, &lt;code&gt;iTXt&lt;/code&gt;, &lt;code&gt;zTXt&lt;/code&gt;, &lt;code&gt;eXIf&lt;/code&gt;, and &lt;code&gt;iCCP&lt;/code&gt;. HEIF, AVIF, and WebP have their own container structures. Cameras write MakerNotes, which are manufacturer-specific and often proprietary. Files can contain embedded thumbnails or previews, and those thumbnails do not have to match the main image forever.&lt;/p&gt;
&lt;p&gt;So yes, there are many other things in the image file besides Exif. &amp;ldquo;Stripping Exif&amp;rdquo; is not the same as &amp;ldquo;removing everything that isn&amp;rsquo;t pixels.&amp;rdquo; There can be all sorts of other things in there, and that&amp;rsquo;s not even counting &lt;a href=&#34;https://backdrifting.net/post/022_fourier_steganography&#34;&gt;encoding data in the image itself with Fourier transforms&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&#34;uploads-and-stripping&#34;&gt;Uploads and stripping&lt;/h2&gt;
&lt;p&gt;Do not assume the operating system strips Exif, or any other metadata, when you upload an image.&lt;/p&gt;
&lt;p&gt;The actual behavior depends on the source image, OS, browser, file picker, app, export path, transformation pipeline, and receiving service. The useful rule is boring:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Expect that images you upload or receive may contain metadata unless you explicitly stripped it or inspected the actual resulting file.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is true in both directions. If you are accepting uploads, do not assume the user&amp;rsquo;s metadata is gone. If you are sending images somewhere else, do not assume your local privacy-sensitive metadata will be removed for you.&lt;/p&gt;
&lt;h2 id=&#34;libraries-and-approaches&#34;&gt;Libraries and approaches&lt;/h2&gt;
&lt;p&gt;I would think about metadata libraries in three buckets.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Wrap &lt;a href=&#34;https://exiftool.org/&#34;&gt;&lt;code&gt;exiftool&lt;/code&gt;&lt;/a&gt;. This is the &amp;ldquo;I need the tool with all the esoteric knowledge&amp;rdquo; option. It is often the right choice for inspection or forensic-ish debugging. But you are shelling out to a Perl program, so you have deployment and performance questions.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Use native libraries or image-processing stacks. That includes C libraries like &lt;a href=&#34;https://libexif.github.io/&#34;&gt;&lt;code&gt;libexif&lt;/code&gt;&lt;/a&gt;, and image libraries like &lt;a href=&#34;https://imagemagick.org/&#34;&gt;ImageMagick&lt;/a&gt; or &lt;a href=&#34;https://www.libvips.org/&#34;&gt;libvips&lt;/a&gt;. A lot of Node and TypeScript developers meet libvips through &lt;a href=&#34;https://sharp.pixelplumbing.com/&#34;&gt;Sharp&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This bucket is what you want when the real job is image processing: autorotate, resize, convert, strip or preserve metadata, write the result. It is not necessarily the same as a comprehensive metadata-inspection tool.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Parse metadata yourself. This can be perfectly reasonable if the task is small and you&amp;rsquo;re okay without covering absolutely every case. &amp;ldquo;Read Exif orientation from JPEG APP1&amp;rdquo; is small. &amp;ldquo;Correctly support Exif, MakerNotes, XMP, IPTC, ICC profiles, C2PA, JPEG, PNG, WebP, HEIC, AVIF, malformed files, and a weird camera someone bought in 2009&amp;rdquo; is not.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A few starting points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JavaScript/TypeScript: &lt;a href=&#34;https://github.com/MikeKovarik/exifr&#34;&gt;&lt;code&gt;exifr&lt;/code&gt;&lt;/a&gt;, &lt;a href=&#34;https://github.com/mattiasw/ExifReader&#34;&gt;&lt;code&gt;ExifReader&lt;/code&gt;&lt;/a&gt;, &lt;a href=&#34;https://github.com/hMatoba/piexifjs&#34;&gt;&lt;code&gt;piexifjs&lt;/code&gt;&lt;/a&gt;, and &lt;a href=&#34;https://sharp.pixelplumbing.com/&#34;&gt;Sharp&lt;/a&gt; for image processing.&lt;/li&gt;
&lt;li&gt;Go: &lt;a href=&#34;https://github.com/barasher/go-exiftool&#34;&gt;&lt;code&gt;barasher/go-exiftool&lt;/code&gt;&lt;/a&gt;, &lt;a href=&#34;https://github.com/dsoprea/go-exif&#34;&gt;&lt;code&gt;dsoprea/go-exif&lt;/code&gt;&lt;/a&gt;, and &lt;a href=&#34;https://github.com/rwcarlsen/goexif&#34;&gt;&lt;code&gt;rwcarlsen/goexif&lt;/code&gt;&lt;/a&gt;, which is popular but quiet these days.&lt;/li&gt;
&lt;li&gt;Python: &lt;a href=&#34;https://pillow.readthedocs.io/&#34;&gt;Pillow&lt;/a&gt; for basic Exif access and image operations, &lt;a href=&#34;https://github.com/ianare/exif-py&#34;&gt;&lt;code&gt;exifread&lt;/code&gt;&lt;/a&gt; for reading, &lt;a href=&#34;https://piexif.readthedocs.io/&#34;&gt;&lt;code&gt;piexif&lt;/code&gt;&lt;/a&gt; for editing JPEG Exif, and &lt;a href=&#34;https://github.com/sylikc/pyexiftool&#34;&gt;&lt;code&gt;PyExifTool&lt;/code&gt;&lt;/a&gt; if you want an &lt;code&gt;exiftool&lt;/code&gt; wrapper.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Some of these libraries are not especially active. That is not automatically disqualifying, because Exif is pretty fixed at this point. Maintenance matters more if you need newer file formats, security fixes, malformed-file handling, or broad tag coverage.&lt;/p&gt;
&lt;h2 id=&#34;my-rules-of-thumb&#34;&gt;My rules of thumb&lt;/h2&gt;
&lt;p&gt;If you process pixels, normalize orientation first.&lt;/p&gt;
&lt;p&gt;If you publish or store user images and want to preserve privacy, strip metadata intentionally.&lt;/p&gt;
&lt;p&gt;If you parse metadata, treat it as untrusted input.&lt;/p&gt;
&lt;p&gt;If you only need one tag, you can maybe parse one tag. If you need all the things, use a tool that has spent decades learning all the things.&lt;/p&gt;
&lt;h2 id=&#34;please-proceed-to-the-exif&#34;&gt;Please proceed to the exif&lt;/h2&gt;
&lt;p&gt;So there you have it. If you made it this far, your interest in image metadata exceeds that of most normal people.&lt;/p&gt;
&lt;p&gt;And if I missed something important, or if you have more stupid Exif tricks you&amp;rsquo;ve seen in the wild, please drop me a note.&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;I could not find a stable direct PDF URL, but CIPA&amp;rsquo;s download endpoint is here: &lt;a href=&#34;https://www.cipa.jp/std/documents/download_e.html?CIPA_DC-008-2026-E&#34;&gt;CIPA DC-008-Translation-2026&lt;/a&gt;. The PDF itself includes a copyright notice and sits behind CIPA&amp;rsquo;s disclaimer.&amp;#160;&lt;a href=&#34;#fnref:1&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:2&#34;&gt;
&lt;p&gt;WebP has an &lt;a href=&#34;https://developers.google.com/speed/webp/docs/riff_container#metadata&#34;&gt;&lt;code&gt;EXIF&lt;/code&gt; chunk&lt;/a&gt;. HEIC/HEIF and AVIF can carry Exif metadata as metadata items in their own container structures. PNG has always had a chunk-based metadata story, and modern PNG also has an &lt;a href=&#34;https://www.w3.org/TR/png-3/#11eXIf&#34;&gt;&lt;code&gt;eXIf&lt;/code&gt; chunk&lt;/a&gt;, but PNG is not the classic Exif habitat.&amp;#160;&lt;a href=&#34;#fnref:2&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:3&#34;&gt;
&lt;p&gt;Image File Directory, the TIFF-ish table of tag entries inside Exif. IFD0 usually describes the main image; IFD1 is the next directory.&amp;#160;&lt;a href=&#34;#fnref:3&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:4&#34;&gt;
&lt;p&gt;In JPEG, an ICC profile is normally stored in &lt;code&gt;APP2&lt;/code&gt;, not inside Exif. In PNG, it can be in &lt;code&gt;iCCP&lt;/code&gt;.&amp;#160;&lt;a href=&#34;#fnref:4&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:5&#34;&gt;
&lt;p&gt;In JPEG, the C2PA manifest store is serialized as JUMBF and embedded in &lt;code&gt;APP11&lt;/code&gt; marker segments. In PNG, the spec uses a &lt;code&gt;caBX&lt;/code&gt; chunk. In BMFF-ish formats, it lives in boxes.&amp;#160;&lt;a href=&#34;#fnref:5&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <title>Starting a VM from a MacOS sandbox</title>
    <link href="https://brentfitzgerald.com/posts/lima-vms-from-a-sandboxed-agent/" />
    <id>https://brentfitzgerald.com/posts/lima-vms-from-a-sandboxed-agent/</id>
    <published>2026-05-27T23:30:00Z</published>
    <updated>2026-05-27T23:30:00Z</updated>
    <summary>Seatbelt allow rules for processes to use virtualization</summary>
    <content type="html">&lt;p&gt;This is a quick tech note about using &lt;a href=&#34;https://lima-vm.io/&#34;&gt;Lima&lt;/a&gt; to start VMs from MacOS sandboxed processes.&lt;/p&gt;
&lt;h2 id=&#34;vms-with-lima&#34;&gt;VMs with Lima&lt;/h2&gt;
&lt;p&gt;Increasingly often I have multiple worktrees in progress, each with an agent working on a specific feature. To isolate all service dependencies and ports, I&amp;rsquo;ve been starting to use throwaway Linux VMs with Lima, and then I&amp;rsquo;ll run docker compose and other app stuff inside of the VM.&lt;/p&gt;
&lt;p&gt;I decided it would be really helpful to make my coding agents also do this. That way they can do a full development and test lifecycle in full isolation without any docker compose port collisions or shared databases.&lt;/p&gt;
&lt;h2 id=&#34;sandboxes-with-seatbelt-and-nono&#34;&gt;Sandboxes with Seatbelt and nono&lt;/h2&gt;
&lt;p&gt;I run each coding agent in a sandbox. I am a little suspicious of the agent-provided sandboxes, which may change over time and seem a bit too clever with options like &lt;code&gt;auto&lt;/code&gt; mode. So I use a separate system.&lt;/p&gt;
&lt;p&gt;On a Mac, sandboxing typically uses Apple&amp;rsquo;s Seatbelt, the kernel-level engine used by &lt;code&gt;sandbox-exec&lt;/code&gt;. A seatbelt profile is a bunch of very explicit rules about what to deny and allow.&lt;/p&gt;
&lt;p&gt;My sandbox uses a Seatbelt profile that I initially constructed with &lt;a href=&#34;https://agent-safehouse.dev/&#34;&gt;Agent Safehouse&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;However, I&amp;rsquo;ve also been recently experimenting with &lt;a href=&#34;https://nono.sh/&#34;&gt;nono&lt;/a&gt;, a capability-based sandbox that also uses Seatbelt under the hood. Theoretically nono can make it easier to compose capabilities (via groups) and to inherit from and override readymade profiles.&lt;/p&gt;
&lt;h2 id=&#34;lima-and-the-virtualization-entitlement&#34;&gt;Lima and the Virtualization entitlement&lt;/h2&gt;
&lt;p&gt;Lima&amp;rsquo;s driver on Apple Silicon is &lt;code&gt;vz&lt;/code&gt;, which uses Apple&amp;rsquo;s Virtualization framework. The framework requires an entitlement. &lt;code&gt;limactl&lt;/code&gt; already ships with it. Here&amp;rsquo;s how you can see it:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;$ codesign -d --entitlements - /opt/homebrew/bin/limactl
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;...
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;o&#34;&gt;[&lt;/span&gt;Key&lt;span class=&#34;o&#34;&gt;]&lt;/span&gt; com.apple.security.virtualization
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;o&#34;&gt;[&lt;/span&gt;Value&lt;span class=&#34;o&#34;&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;o&#34;&gt;[&lt;/span&gt;Bool&lt;span class=&#34;o&#34;&gt;]&lt;/span&gt; &lt;span class=&#34;nb&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So the binary is allowed to make VMs. So far so good!&lt;/p&gt;
&lt;h2 id=&#34;the-failure&#34;&gt;The failure&lt;/h2&gt;
&lt;p&gt;Unfortunately running &lt;code&gt;limactl start&lt;/code&gt; with my sandbox profile failed with an error:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[hostagent] Starting VZ
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[hostagent] Setting up Rosetta share
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;fatal: Error Domain=VZErrorDomain Code=1 Description=&amp;#34;Internal Virtualization error. The virtual machine failed to start.&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Plus some other issues:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;failed to detect system DNS, falling back to [8.8.8.8 1.1.1.1]  error=&amp;#34;open /etc/resolv.conf: operation not permitted&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The DNS warning is just an issue of read access on &lt;code&gt;/etc/resolv.conf&lt;/code&gt;, and there&amp;rsquo;s nothing sensitive in there. The real problem is &lt;code&gt;VZErrorDomain Code=1&lt;/code&gt;. It says &amp;ldquo;Internal Virtualization error&amp;rdquo; but does not say what was denied exactly.&lt;/p&gt;
&lt;p&gt;Since the error occurred right after &amp;ldquo;Setting up Rosetta share&amp;rdquo; I thought at first it looked like a Rosetta problem (which turned out to not be the case).&lt;/p&gt;
&lt;p&gt;The Seatbelt denial was not in Lima&amp;rsquo;s logs. Lima only sees the generic VZ error. To find the Seatbelt logs I had to look at the MacOS unified log, written by the kernel.&lt;/p&gt;
&lt;p&gt;My initial &lt;code&gt;log show&lt;/code&gt; queries found nothing. The denials are emitted by the kernel in a specific format and it was hard to figure out the right predicate to use. I eventually found it by filtering on the literal &lt;code&gt;Sandbox: &lt;/code&gt; prefix:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;log stream --style compact --predicate &amp;#39;eventMessage CONTAINS &amp;#34;Sandbox: &amp;#34;&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I tested this by triggering a denial I understood: read a blocked file under the sandbox. The log showed it:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Sandbox: wc(89150) deny(1) file-read-data /private/etc/sudoers
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Good. Logging works. The format is &lt;code&gt;Sandbox: &amp;lt;process&amp;gt;(&amp;lt;pid&amp;gt;) deny(1) &amp;lt;operation&amp;gt; &amp;lt;detail&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&#34;the-denials&#34;&gt;The denials&lt;/h2&gt;
&lt;p&gt;With the stream running, I started the VM again. Here&amp;rsquo;s the deny I saw:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Sandbox: limactl(42303) deny(1) mach-lookup com.apple.Virtualization.VirtualMachine (xpc)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So to start a VM, the Virtualization framework tries to look up an XPC service named &lt;code&gt;com.apple.Virtualization.VirtualMachine&lt;/code&gt;. That service is the process that actually hosts the guest. The sandbox did not allow the lookup, the lookup failed, and the framework returned &lt;code&gt;Code=1&lt;/code&gt; with no detail.&lt;/p&gt;
&lt;p&gt;After some research and with agent assistance, I learned there&amp;rsquo;s a way to write a rule to allow a specific mach-lookup:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-scheme&#34; data-lang=&#34;scheme&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;allow&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;mach-lookup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;global-name&lt;/span&gt; &lt;span class=&#34;s&#34;&gt;&amp;#34;com.apple.Virtualization.VirtualMachine&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then I tried again. I got past that denial, and more denials appeared, in sequence, as we got further:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;limactl deny(1) mach-task-name others [com.apple.Virtualization.Virtual(44215)]
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;limactl deny(1) generic-issue-extension extension-class:com.apple.virtualization.extension.fuse
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;limactl deny(1) generic-issue-extension extension-class:com.apple.virtualization.extension.rosetta-directory-share
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each one is a step in starting the VM:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mach-task-name&lt;/code&gt; took me a bit. It is the framework getting a handle to the host process it just spawned. That process runs &lt;em&gt;outside&lt;/em&gt; the sandbox since launchd starts it. The parent needs its task-name to monitor it.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;fuse&lt;/code&gt; extension is the virtio-fs directory share, used to mount the host filesystem into the VM.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;rosetta-directory-share&lt;/code&gt; extension is the Rosetta mount that lets the guest run x86-64 binaries. The VM boots without it, but Rosetta will not work in the guest unless you allow it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;seatbelt-rules-to-enable-lima-vm-start&#34;&gt;Seatbelt rules to enable Lima VM start&lt;/h2&gt;
&lt;p&gt;Here are the four things I had to change, in Seatbelt form. Three are virtualization-specific:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-scheme&#34; data-lang=&#34;scheme&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;allow&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;mach-lookup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;global-name&lt;/span&gt; &lt;span class=&#34;s&#34;&gt;&amp;#34;com.apple.Virtualization.VirtualMachine&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;allow&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;mach-task-name&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;allow&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;generic-issue-extension&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;extension-class&lt;/span&gt; &lt;span class=&#34;s&#34;&gt;&amp;#34;com.apple.virtualization.extension.fuse&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;extension-class&lt;/span&gt; &lt;span class=&#34;s&#34;&gt;&amp;#34;com.apple.virtualization.extension.rosetta-directory-share&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The fourth was just file access. Lima keeps per-instance state and sockets under &lt;code&gt;~/.lima&lt;/code&gt;, and downloads guest images into &lt;code&gt;~/Library/Caches/lima&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-scheme&#34; data-lang=&#34;scheme&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;allow&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;file-read*&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;file-write*&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;subpath&lt;/span&gt; &lt;span class=&#34;s&#34;&gt;&amp;#34;/Users/you/.lima&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;subpath&lt;/span&gt; &lt;span class=&#34;s&#34;&gt;&amp;#34;/Users/you/Library/Caches/lima&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The DNS warning was worth fixing too. &lt;code&gt;/etc/resolv.conf&lt;/code&gt; is a symlink to &lt;code&gt;/var/run/resolv.conf&lt;/code&gt;. My profile allowed the symlink path but not the real target, so the read failed and Lima fell back to other DNS. The fix is allowing a file-read:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-scheme&#34; data-lang=&#34;scheme&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;allow&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;file-read*&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;literal&lt;/span&gt; &lt;span class=&#34;s&#34;&gt;&amp;#34;/private/var/run/resolv.conf&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So I added the virtualization rules in a small block in my &lt;code&gt;.sb&lt;/code&gt; file next to Lima file grants.&lt;/p&gt;
&lt;p&gt;Now the VM boots to &lt;code&gt;READY&lt;/code&gt; inside the sandbox. Yay!&lt;/p&gt;
&lt;p&gt;One note on &lt;code&gt;(allow mach-task-name)&lt;/code&gt; is that it&amp;rsquo;s broad, and I would rather it weren&amp;rsquo;t. Seatbelt lets you scope task-port rules to &lt;code&gt;same-sandbox&lt;/code&gt;, but the VM host process is not in the sandbox, so that scope does not match it. I don&amp;rsquo;t see a clean way to scope to a single executable. I think the risk is small. The task name is used for introspection, but the process still cannot read the target&amp;rsquo;s memory or control its execution.&lt;/p&gt;
&lt;h2 id=&#34;in-nono&#34;&gt;In nono&lt;/h2&gt;
&lt;p&gt;nono&amp;rsquo;s model is higher level so you don&amp;rsquo;t have to touch the s-expression syntax. It&amp;rsquo;s files, network, and credentials. You grant directories and domains, but I couldn&amp;rsquo;t find schema support for mach-lookup or a sandbox extension, and no built-in group for Lima or virtualization.&lt;/p&gt;
&lt;p&gt;However, since it uses Seatbelt under the hood on Macs, nono does expose &lt;a href=&#34;https://github.com/always-further/nono/blob/fb9be2d87ad4ec834bd2f44c7f8c156fb5e42262/crates/nono-cli/data/nono-profile.schema.json#L123&#34;&gt;&lt;code&gt;unsafe_macos_seatbelt_rules&lt;/code&gt;&lt;/a&gt; to provide custom, untyped rules. It&amp;rsquo;s a list of raw Seatbelt S-expressions applied verbatim. The file grants map to normal nono capabilities. The three virtualization rules go in there:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-json&#34; data-lang=&#34;json&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  &lt;span class=&#34;nt&#34;&gt;&amp;#34;filesystem&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;nt&#34;&gt;&amp;#34;allow&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;~/.lima&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;~/Library/Caches/lima&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;nt&#34;&gt;&amp;#34;read_file&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;/private/var/run/resolv.conf&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  &lt;span class=&#34;p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  &lt;span class=&#34;nt&#34;&gt;&amp;#34;unsafe_macos_seatbelt_rules&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s2&#34;&gt;&amp;#34;(allow mach-lookup (global-name \&amp;#34;com.apple.Virtualization.VirtualMachine\&amp;#34;))&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s2&#34;&gt;&amp;#34;(allow mach-task-name)&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;s2&#34;&gt;&amp;#34;(allow generic-issue-extension (extension-class \&amp;#34;com.apple.virtualization.extension.fuse\&amp;#34;) (extension-class \&amp;#34;com.apple.virtualization.extension.rosetta-directory-share\&amp;#34;))&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  &lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So the Lima file grants are straightforward, but letting the child process use an XPC host service and get task names is the part where we need raw Seatbelt.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve looked into contributing this as a &lt;a href=&#34;https://nono.sh/docs/cli/features/package-publishing&#34;&gt;nono &amp;ldquo;pack&amp;rdquo;&lt;/a&gt;, but currently nono doesn&amp;rsquo;t support composing multiple profiles, so someone using this couldn&amp;rsquo;t say &amp;ldquo;I want claude + lima support.&amp;rdquo; nono&amp;rsquo;s composition mechanism seems to be groups, but because groups are designed to be either included or excluded, they don&amp;rsquo;t support the generic &lt;code&gt;unsafe_macos_seatbelt_rules&lt;/code&gt; entry available to profiles.&lt;/p&gt;
&lt;h2 id=&#34;takeaway-notes-for-other-travelers&#34;&gt;Takeaway notes for other travelers&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;VZErrorDomain Code=1&lt;/code&gt; doesn&amp;rsquo;t say much, but if the Virtualization framework fails to start inside a sandbox, it&amp;rsquo;s probably the denied &lt;code&gt;mach-lookup&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Seatbelt denials are in the unified log, written by the kernel, under the &lt;code&gt;Sandbox: &lt;/code&gt; prefix. So you can &lt;code&gt;log stream&lt;/code&gt; looking for that.&lt;/li&gt;
&lt;li&gt;The minimal set for Lima &lt;code&gt;vz&lt;/code&gt; to start a VM via &lt;code&gt;limactl&lt;/code&gt;: the &lt;code&gt;com.apple.Virtualization.VirtualMachine&lt;/code&gt; mach-lookup, &lt;code&gt;mach-task-name&lt;/code&gt; for the host process, and the two virtualization issue-extensions. Plus file access to &lt;code&gt;~/.lima&lt;/code&gt; and its cache.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I&amp;rsquo;ve started a &lt;a href=&#34;https://github.com/always-further/nono/discussions/1043&#34;&gt;nono discussion&lt;/a&gt; about documenting these rules, composing them into a pack, and maybe promoting them to a first-class capability.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Messing with Chrome&#39;s local Gemini Nano to Deobfuscate LinkedIn posts</title>
    <link href="https://brentfitzgerald.com/posts/linkedin-translator-browser-extension/" />
    <id>https://brentfitzgerald.com/posts/linkedin-translator-browser-extension/</id>
    <published>2026-05-13T19:00:00Z</published>
    <updated>2026-05-13T19:00:00Z</updated>
    <summary>I used the new Chrome local model and Prompt APIs to create an extension to dumb down my professional network and also learned things about humans along the way.</summary>
    <content type="html">&lt;p&gt;Last week Google generously deigned to &lt;a href=&#34;https://www.pcmag.com/news/chrome-is-quietly-downloading-4gb-ai-model-without-your-permission&#34;&gt;drop a 4GB Nano model onto our machines&lt;/a&gt;. So I naturally &lt;a href=&#34;https://github.com/mono-koto/linkedin-translator&#34;&gt;created&lt;/a&gt; a browser extension that uses that local model to translate LinkedIn posts into simple, mostly dumb, occasionally heartfelt statements.&lt;/p&gt;
&lt;figure class=&#34;space-y-3&#34;&gt;
  &lt;video
    src=&#34;https://brentfitzgerald.com/posts/linkedin-translator/linkedin-translator-demo.mp4&#34;
    class=&#34;w-full&#34;
    autoplay
    muted
    loop
    playsinline
    controls
    aria-label=&#34;Scrolling demo of the effects of this browser extension translating LinkedIn things&#34;
  &gt;&lt;/video&gt;
  &lt;figcaption class=&#34;text-fg-muted&#34;&gt;
    Demo: Is it good? Yes.
  &lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&#34;why&#34;&gt;Why&lt;/h2&gt;
&lt;p&gt;I read some complaints that Chrome was eating 4GB of disk for AI nobody asked for. I has a learning project in mind and figured I might as well make it &amp;ldquo;useful.&amp;rdquo; I&amp;rsquo;m normally a Firefox user, but I was curious what this LLM could do, and also how this would be exposed via Web APIs.&lt;/p&gt;
&lt;p&gt;I also guessed that a dumb local model would be a good fit for a dumb local project. This was &lt;a href=&#34;https://brentfitzgerald.com/posts/in-praise-of-tinkering/&#34;&gt;tinkering&lt;/a&gt;. I wasn&amp;rsquo;t trying to get anywhere in particular. I had more pressing things to work on. But I did this instead.&lt;/p&gt;
&lt;h2 id=&#34;how-you-call-it&#34;&gt;How you call it&lt;/h2&gt;
&lt;p&gt;The model is called Gemini Nano. Extensions and frontend code call it through a &lt;a href=&#34;https://developer.chrome.com/docs/ai/prompt-api&#34;&gt;Prompt API&lt;/a&gt;. This stuff was initially introduced over a year ago. It&amp;rsquo;s just getting news lately due to the model download.&lt;/p&gt;
&lt;p&gt;The API is small. Check that the model is there, create a session with a system prompt, call &lt;code&gt;prompt()&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-js&#34; data-lang=&#34;js&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;((&lt;/span&gt;&lt;span class=&#34;kr&#34;&gt;await&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;LanguageModel&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;availability&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;())&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;===&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;unavailable&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt; &lt;span class=&#34;k&#34;&gt;return&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;kr&#34;&gt;const&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;session&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;kr&#34;&gt;await&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;LanguageModel&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;create&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  &lt;span class=&#34;nx&#34;&gt;initialPrompts&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[{&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;role&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;system&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;content&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;SYSTEM_PROMPT&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;}],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;kr&#34;&gt;const&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;reply&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;kr&#34;&gt;await&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;session&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;prompt&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;postText&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;There&amp;rsquo;s a &lt;code&gt;clone()&lt;/code&gt; so you can fork a session, so each post gets a clean slate:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-js&#34; data-lang=&#34;js&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;kr&#34;&gt;const&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;fresh&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;kr&#34;&gt;await&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;session&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;clone&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;kr&#34;&gt;const&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;reply&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;kr&#34;&gt;await&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;fresh&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;prompt&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;postText&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nx&#34;&gt;fresh&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;destroy&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If the model hasn&amp;rsquo;t been downloaded yet, &lt;code&gt;create()&lt;/code&gt; triggers the download and resolves once it&amp;rsquo;s done. You can hook a &lt;code&gt;monitor&lt;/code&gt; to get progress events:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-js&#34; data-lang=&#34;js&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;kr&#34;&gt;const&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;session&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;kr&#34;&gt;await&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;LanguageModel&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;create&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  &lt;span class=&#34;nx&#34;&gt;initialPrompts&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[{&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;role&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;system&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;content&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;SYSTEM_PROMPT&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;}],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  &lt;span class=&#34;nx&#34;&gt;monitor&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;m&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;nx&#34;&gt;m&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;addEventListener&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;downloadprogress&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;nx&#34;&gt;e&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;      &lt;span class=&#34;nx&#34;&gt;console&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;log&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;sb&#34;&gt;`download: &lt;/span&gt;&lt;span class=&#34;si&#34;&gt;${&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;e&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;loaded&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;*&lt;/span&gt; &lt;span class=&#34;mi&#34;&gt;100&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;).&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;toFixed&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;si&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;sb&#34;&gt;%`&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;p&#34;&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  &lt;span class=&#34;p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My extension does nothing with these events. If the model isn&amp;rsquo;t there, the first call just awaits while a few gigabytes come down in the background. An enterprising 10x developer on a productive day would put a little progress bar in the UI. Apparently I am not that developer, and today is definitely not that day.&lt;/p&gt;
&lt;p&gt;The system prompt itself is mostly me haranguing the model to be dumb. The &lt;a href=&#34;https://github.com/mono-koto/linkedin-translator/blob/main/extension/src/prompt.ts&#34;&gt;full prompt&lt;/a&gt; is on GitHub.&lt;/p&gt;
&lt;p&gt;Follow me for more prompt engineering best practices.&lt;/p&gt;
&lt;h2 id=&#34;what-i-learned&#34;&gt;What I learned&lt;/h2&gt;
&lt;h3 id=&#34;local-inference-is-exciting&#34;&gt;Local inference is exciting&lt;/h3&gt;
&lt;p&gt;This was the first real time I spent with a modern, small, &lt;em&gt;good&lt;/em&gt; model. I&amp;rsquo;d understood the advantages in theory, like that it&amp;rsquo;s local, private, fast, and cheap. But actually playing with it shifted my perspective. From least to most important:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Surprisingly fast.&lt;/strong&gt; My macbook m4 is not low end hardware, but I still didn&amp;rsquo;t expect it to zip through a feed of LinkedIn posts in seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Low stakes.&lt;/strong&gt; No rate limits, no bills to worry about. Costs me microcents of electricity and a bit of patience.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;So, so simple.&lt;/strong&gt; No keys, no auth, no workers, gateways, backends, no logs some company might look at. For a private, low-stakes thing like rewriting your own feed, this is a near ideal fit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Creative constraints.&lt;/strong&gt; This is the part I find most interesting. A 4GB model is too limited to be a great conversation partner. You can&amp;rsquo;t be friends with Nano. It&amp;rsquo;s not going to write a good essay. But those limitations point me to use cases I find exciting: small inference jobs that smooth out our experiences. Tight local tools instead of big cloud agents.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;simpler-language-reveals-humanity&#34;&gt;Simpler language reveals humanity&lt;/h3&gt;
&lt;p&gt;Among the b2b sales tips, I spotted authentic posts with genuine sentiments, and at times even some vulnerability. People lose jobs, or ask for help finding jobs. Or they want to talk about colleagues who have passed, who they miss.&lt;/p&gt;
&lt;p&gt;I was relieved to see that when I apply the model to posts by people who care and are trying to express something genuine, it didn&amp;rsquo;t come across as glib or mean. Seeing people&amp;rsquo;s thoughts in super simple form is surprisingly sweet and gentle.&lt;/p&gt;
&lt;h3 id=&#34;i-can-hear-the-inference&#34;&gt;I can hear the inference&lt;/h3&gt;
&lt;p&gt;My computer&amp;rsquo;s fans spin up in a distinctive way when the extension is looping through the batch of posts, running the model on each. The first time I heard it, I laughed because it reminded me of the way I sometimes debugged in college using &lt;code&gt;printf(&amp;quot;\a&amp;quot;)&lt;/code&gt; bells. Computation you can &lt;em&gt;hear!&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&#34;i-am-not-sure-i-like-this-in-a-browser-api&#34;&gt;I am not sure I like this in a browser API&lt;/h3&gt;
&lt;p&gt;Any website can call it. The model is sitting on &lt;em&gt;my&lt;/em&gt; machine, causing it to make weird little noises. Google decided that it&amp;rsquo;s callable by anyone whose JS you happen to execute. It should probably be a permission, like camera or microphone. The API is in &lt;a href=&#34;https://developer.chrome.com/docs/web-platform/origin-trials&#34;&gt;Origin Trial&lt;/a&gt;, which apparently means it&amp;rsquo;s already callable on real users by any site with a registered trial token. Stable is supposed to ship sometime in late 2026 or early 2027. Maybe there will be a permission gate before then? But the way it looks right now is that Google is quietly slipping a few GB of model onto everyone&amp;rsquo;s host with the idea any site can eventually invoke it. I&amp;rsquo;m using it because it&amp;rsquo;s there and it&amp;rsquo;s interesting. I&amp;rsquo;d be more comfortable if the site asked the user first.&lt;/p&gt;
&lt;h3 id=&#34;terseness-is-unnatural-for-models&#34;&gt;Terseness is unnatural for models&lt;/h3&gt;
&lt;p&gt;I&amp;rsquo;ve seen this before actually, and this was another case of it. &amp;ldquo;Brevity is the soul of wit&amp;rdquo; when there&amp;rsquo;s no wit.&lt;/p&gt;
&lt;p&gt;It was hard to make the model just do the dumb straightforward thing. Nano really wanted to be helpful with a tidy summary. Getting it to just say &amp;ldquo;&lt;em&gt;Some people made a thing that is good&lt;/em&gt;&amp;rdquo; took more prompt-tuning than I expected, and it still isn&amp;rsquo;t quite what I was aiming for.&lt;/p&gt;
&lt;h3 id=&#34;scraping-doms-sucks&#34;&gt;Scraping DOMs sucks&lt;/h3&gt;
&lt;p&gt;The selectors are… best effort. It took fiddling with this obfuscated DOM trying to find the parts to translate. Something I want to come back to: could a local model dynamically identify semantically meaningful text instead of brittle CSS selectors?&lt;/p&gt;
&lt;p&gt;Perhaps this is our GPUtiful future:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-css&#34; data-lang=&#34;css&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;#&lt;/span&gt;&lt;span class=&#34;nn&#34;&gt;root&lt;/span&gt; &lt;span class=&#34;nt&#34;&gt;section&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;prompt&lt;/span&gt;&lt;span class=&#34;err&#34;&gt;≈&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;insipid thought leadership content&amp;#34;&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id=&#34;is-this-ok&#34;&gt;Is this OK?&lt;/h2&gt;
&lt;p&gt;It&amp;rsquo;s a learning project, open source, MIT licensed, all local, not going in the Chrome Web Store. But these days, who knows? It&amp;rsquo;s possible this is against someone&amp;rsquo;s terms.&lt;/p&gt;
&lt;p&gt;If the serious people prefer we don&amp;rsquo;t do things like this, they can drop me a friendly note.&lt;/p&gt;
&lt;h2 id=&#34;try-it&#34;&gt;Try it&lt;/h2&gt;
&lt;p&gt;repo: &lt;a href=&#34;https://github.com/mono-koto/linkedin-translator&#34;&gt;mono-koto/linkedin-translator&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Warning: It&amp;rsquo;s dumb. It will certainly break.&lt;/p&gt;
&lt;p&gt;But if you want to try it, &lt;a href=&#34;https://github.com/mono-koto/linkedin-translator/releases/latest&#34;&gt;get the zip&lt;/a&gt;. You&amp;rsquo;ll need to sideload it. Directions in the &lt;a href=&#34;https://github.com/mono-koto/linkedin-translator/blob/main/README.md&#34;&gt;README&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Then: set some Chrome flags, wait for the model to download, set extensions to dev mode, sideload the unpacked extension, open your favorite professional networking website, voila.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>On restarting a blog in 2026</title>
    <link href="https://brentfitzgerald.com/posts/on-restarting-a-blog-in-2026/" />
    <id>https://brentfitzgerald.com/posts/on-restarting-a-blog-in-2026/</id>
    <published>2026-05-05T00:00:00Z</published>
    <updated>2026-05-05T00:00:00Z</updated>
    <summary>It&#39;s challenging to start posting after a long time out of the habit. It&#39;s also tough to say no to autocompleting my brain.</summary>
    <content type="html">&lt;p&gt;It&amp;rsquo;s been 20+ years since I started this domain. It has been a portfolio, a research blog, listings of projects, other attempts at blogs&amp;hellip; and then it was stagnant for a long time.&lt;/p&gt;
&lt;p&gt;Compared to what I used to consider worth posting two decades ago, I now have tons of work and life experience to draw from. But over all that time, I&amp;rsquo;ve followed no consistent practice for recording or distilling. My memories are smeared across an array of journals, apps, files, emails, chats, and sticky notes.&lt;/p&gt;
&lt;p&gt;There is also the problem of the &lt;a href=&#34;https://academic.oup.com/cercor/article/33/7/4156/6691667&#34;&gt;shrinking dad brain&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;More than any of that though, a challenge unique to this era is suppressing the recurring bad idea to autocomplete my unbaked thoughts. In software work, I use LLMs &lt;em&gt;a lot&lt;/em&gt;. They handle some tasks that require real depth. But when I try to apply them to writing, I don&amp;rsquo;t fully incubate the ideas, and the results just have that LLM smell.&lt;/p&gt;
&lt;p&gt;All to say: returning to writing after a long time has &lt;em&gt;not&lt;/em&gt; been just like riding a bike. It&amp;rsquo;s been slow, unsteady, and awkward. I keep wanting to drive or take a bus or just stay home and eat Mini Mint It&amp;rsquo;s Its, &lt;sup id=&#34;fnref:1&#34;&gt;&lt;a href=&#34;#fn:1&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt; but I&amp;rsquo;m persevering.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve restarted this site by migrating a number of posts from &lt;a href=&#34;https://bearblog.dev&#34;&gt;Bearblog&lt;/a&gt;, which I was using to warm up my voice again. I don&amp;rsquo;t think I&amp;rsquo;ve actually warmed it up, but I&amp;rsquo;m less afraid now. Bear has been great for just getting the words out into the great ether. Real humans even saw my words and occasionally upvoted them. I may continue posting the fragmented stuff there.&lt;/p&gt;
&lt;p&gt;And here I&amp;rsquo;ll post the (hopefully) more polished thoughts that might actually be useful.&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;the classic bay area dessert, now in a daintier format.&amp;#160;&lt;a href=&#34;#fnref:1&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <title>Fourth-grade product thinking</title>
    <link href="https://brentfitzgerald.com/posts/fourth-grade-product-thinking/" />
    <id>https://brentfitzgerald.com/posts/fourth-grade-product-thinking/</id>
    <published>2026-04-25T00:00:00Z</published>
    <updated>2026-04-25T00:00:00Z</updated>
    <summary>My fourth grader handed me his latest ideas for the chat app we&#39;re building. The &#34;useless&#34; delight features are the ones that make people love your product.</summary>
    <content type="html">&lt;p&gt;My fourth grader handed me this last night. It&amp;rsquo;s his latest ideas for the chat app we&amp;rsquo;ve been building together.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://brentfitzgerald.com/posts/fourth-grade-product-thinking/feature-list.jpg&#34; alt=&#34;Three columns of feature ideas labeled extra things, commands, and big things&#34;&gt;&lt;/p&gt;
&lt;p&gt;Three columns: extra things, commands, big things.&lt;/p&gt;
&lt;p&gt;Extra things include profile icons, a suggestion box, a vote box for the suggestions, a voice call feature (&amp;ldquo;not a video call but a voice call that can be used to chat without typing&amp;rdquo;), and one-on-one chats.&lt;/p&gt;
&lt;p&gt;Commands include &lt;code&gt;/disco&lt;/code&gt; (discoball for 30 seconds), &lt;code&gt;/rainbow&lt;/code&gt; and &lt;code&gt;/rainbow off&lt;/code&gt;, and &lt;code&gt;/archive&lt;/code&gt; for saving messages so they don&amp;rsquo;t expire.&lt;/p&gt;
&lt;p&gt;The &amp;ldquo;big things&amp;rdquo; column is empty. He hasn&amp;rsquo;t gotten to it yet.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve seen seed decks with way worse product thinking. He independently invented feature voting. He scoped voice as a communication modality distinct from video. He understood that knowledge lives in messages so we need a persistence system.&lt;/p&gt;
&lt;p&gt;He also thinks a discoball command is core. And he&amp;rsquo;s right. The &amp;ldquo;useless&amp;rdquo; delight features are actually what make people love your product.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a question I often ask myself to get motivated: what would this look like if it were actually fun? But a kid never has to ask that. They start with fun. Rainbow came before the architecture, discoball came before the roadmap.&lt;/p&gt;
&lt;p&gt;Most adult product work moves in the opposite direction. We build the serious thing first and bolt the joy on later, if there&amp;rsquo;s time and budget, or if it survives the prioritization meeting. Typically it doesn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;He&amp;rsquo;s going to fill in the &amp;ldquo;big things&amp;rdquo; column eventually. I&amp;rsquo;m curious what ends up there!&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>User context can betray your users</title>
    <link href="https://brentfitzgerald.com/posts/user-context-can-betray-your-users/" />
    <id>https://brentfitzgerald.com/posts/user-context-can-betray-your-users/</id>
    <published>2026-03-23T17:00:00Z</published>
    <updated>2026-03-23T17:00:00Z</updated>
    <summary>When personalization hurts the people it&#39;s meant to help.</summary>
    <content type="html">&lt;p&gt;I wrote &lt;a href=&#34;https://enrichnetwork.io/blog/user-context-personalization-feeds-model-biases&#34;&gt;another post&lt;/a&gt; on the &lt;a href=&#34;https://enrichnetwork.io/blog&#34;&gt;Enrich Network blog&lt;/a&gt;, this time on user context as a bias vector, and what builders owe the users who&amp;rsquo;ll feel it first.&lt;/p&gt;
&lt;a href=&#34;https://enrichnetwork.io/blog/user-context-personalization-feeds-model-biases&#34;&gt;
  &lt;figure&gt;
    &lt;img
      src=&#34;https://brentfitzgerald.com/posts/user-context-can-betray-your-users/cover.svg&#34;
      alt=&#34;Abstract teal composition with soft gradient ellipses and small angular line shapes scattered across the canvas.&#34;
      width=&#34;1920&#34;
      height=&#34;960&#34;
    /&gt;
    &lt;figcaption&gt;User context we feed to LLMs also feeds their biases. How do we guard against this? (image courtesy Enrich Network)&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/a&gt;
&lt;p&gt;The short version: the instinct to personalize is human and seems positive. You put the user&amp;rsquo;s name, language background, and education level into the prompt. Then the model takes that context and stereotypes against it. Your tailoring becomes its bias.&lt;/p&gt;
&lt;p&gt;Recent &lt;a href=&#34;https://news.mit.edu/2026/study-ai-chatbots-provide-less-accurate-information-vulnerable-users-0219&#34;&gt;MIT work&lt;/a&gt; makes this concrete. When user bios implied lower English proficiency or less formal education, GPT-4, Claude 3 Opus, and Llama 3 all gave less accurate, less truthful answers. Claude refused nearly 11% of questions from those users (about three times the control rate) and used condescending language 43.7% of the time, against under 1% for the highly-educated cohort. Same questions. Different framing of the user.&lt;/p&gt;
&lt;p&gt;The naive, well-meaning implementation is the harmful one. If you&amp;rsquo;re building AI for regular people, you owe them more than your good intentions. Check whether your context strategy actually helps the users you say you&amp;rsquo;re trying to serve. The failure mode is invisible from the demo.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;See also: &lt;a href=&#34;https://brentfitzgerald.com/posts/designing-for-trust-in-ai-chat&#34;&gt;Designing for trust in AI chat&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>A LayerZero CLI for cross-chain PYUSD</title>
    <link href="https://brentfitzgerald.com/posts/layerzero-cli-crosschain-pyusd/" />
    <id>https://brentfitzgerald.com/posts/layerzero-cli-crosschain-pyusd/</id>
    <published>2026-01-20T19:27:00Z</published>
    <updated>2026-01-20T19:27:00Z</updated>
    <summary>A CLI and reference implementation for cross-chain PYUSD transfers using LayerZero.</summary>
    <content type="html">&lt;p&gt;I built a CLI and reference implementation for cross-chain PYUSD transfers using LayerZero.&lt;/p&gt;
&lt;p&gt;Repo: &lt;a href=&#34;https://github.com/mono-koto/pyusd-layerzero-cli&#34;&gt;mono-koto/pyusd-layerzero-cli&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;LayerZero&amp;rsquo;s docs are solid on protocol and architecture, and they give you the configuration references you need. But moving PYUSD from one chain to another right now means assembling a lot yourself: PYUSD is represented differently on different chains, some transfer paths require intermediate steps, testnets don&amp;rsquo;t have existing PYUSD0 deployments, and the most useful libs live in a devtools repo you only find by poking around.&lt;/p&gt;
&lt;p&gt;This is an underappreciated category of work — the end-to-end implementation that should exist but doesn&amp;rsquo;t yet. The code itself is mostly a byproduct. The primary value is showing what&amp;rsquo;s possible.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re working with PYUSD across chains, the repo is a reasonable place to start.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Designing for trust in AI chat at Enrich</title>
    <link href="https://brentfitzgerald.com/posts/designing-for-trust-in-ai-chat/" />
    <id>https://brentfitzgerald.com/posts/designing-for-trust-in-ai-chat/</id>
    <published>2025-12-18T21:45:00Z</published>
    <updated>2025-12-18T21:45:00Z</updated>
    <summary>AI chat creates intimacy and trust easily. Designers have a choice about whether to exploit that or treat it with restraint.</summary>
    <content type="html">&lt;p&gt;I wrote &lt;a href=&#34;https://enrichnetwork.io/blog/conversations-that-earn-trust-designing-personal-finance-ai&#34;&gt;a post&lt;/a&gt; on the &lt;a href=&#34;https://enrichnetwork.io/&#34;&gt;Enrich Network&lt;/a&gt; blog about designing for trust in personal finance AI chat.&lt;/p&gt;
&lt;a href=&#34;https://enrichnetwork.io/blog/conversations-that-earn-trust-designing-personal-finance-ai&#34;&gt;
  &lt;figure&gt;
    &lt;img
      src=&#34;https://brentfitzgerald.com/posts/designing-for-trust-in-ai-chat/cover.png&#34;
      alt=&#34;Three illustrated speech bubbles on a teal background — one containing a banknote, one with a bar chart whose edges are pixelated and breaking apart, and one containing a stack of coins.&#34;
      width=&#34;1920&#34;
      height=&#34;1008&#34;
    /&gt;
    &lt;figcaption&gt;Conversations that earn trust: how we’re designing personal finance AI at Enrich (image courtesy Enrich Network)&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/a&gt;
&lt;p&gt;The short version: AI chat instills trust easily. It&amp;rsquo;s consistently available, patient, and non-judgmental. All these properties are important for sensitive topics and vulnerable users. Unfortunately, that same false intimacy is easy to exploit, and that exploitation is becoming a dominant AI dark pattern.&lt;/p&gt;
&lt;p&gt;Earned trust beats vibed trust. As designers and builders, it&amp;rsquo;s important to consider where to leverage AI chat&amp;rsquo;s natural warmth, where to maintain distance, and where to swap in accurate tools for cases where data and logic fidelity matter.&lt;/p&gt;
&lt;p&gt;In researching this I gathered some studies worth knowing about:&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://academic.oup.com/iwc/article/36/5/279/7692197&#34;&gt;Fear of Judgment in Human-Chatbot Interaction&lt;/a&gt; — Oxford, 2024. People perceived less fear of judgment when talking to chatbots compared to humans.&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://pmc.ncbi.nlm.nih.gov/articles/PMC10189503/&#34;&gt;Trust Formation in Chatbot Interactions&lt;/a&gt; — PMC, 2023. Chatbots&amp;rsquo; consistent availability and non-judgmental presence contribute to building user trust in service contexts.&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://arxiv.org/abs/2410.21596&#34;&gt;Companion Chatbots and Loneliness&lt;/a&gt; — MIT, 2024. Companion chatbot use doesn&amp;rsquo;t directly predict loneliness; the relationship is mediated by individual factors like neuroticism and social attraction.&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://arxiv.org/abs/2503.17473&#34;&gt;Daily AI Companion Use and Psychosocial Outcomes&lt;/a&gt; — 2025. In a study of ~1,000 participants, higher daily chatbot usage correlated with worse psychosocial outcomes across the board.&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://www.brown.edu/news/2025-10-21/ai-mental-health-ethics&#34;&gt;AI Mental Health Chatbot Ethics&lt;/a&gt; — Brown University, 2025. Many chatbots fail basic ethical standards for sensitive conversations, including poor crisis management and creating false impressions of empathy.&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://cse.umn.edu/college/news/new-research-shows-ai-chatbots-should-not-replace-your-therapist&#34;&gt;AI Chatbots vs. Licensed Therapists&lt;/a&gt; — University of Minnesota, 2025. AI chatbots responded appropriately to mental health scenarios only 60% of the time, compared to 93% for licensed therapists.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>The sensations of building software with LLMs</title>
    <link href="https://brentfitzgerald.com/posts/sensations-building-software-with-llms/" />
    <id>https://brentfitzgerald.com/posts/sensations-building-software-with-llms/</id>
    <published>2025-12-06T20:02:00Z</published>
    <updated>2025-12-06T20:02:00Z</updated>
    <summary>Writing software with agents feels very different compared to manual coding. I&#39;ve been wondering about the subjective experiences we lose, and what new sensations might replace them.</summary>
    <content type="html">&lt;p&gt;&lt;em&gt;Note: This post was surprisingly challenging to write! I&amp;rsquo;m not accustomed to describing the sensations and textures of an experience that is, at the end of the day, largely cerebral minus some clickety clacks on a keyboard. I think it was worth trying, if only to exercise self-observation.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I sometimes notice that I miss a kind of &lt;em&gt;mental sensation&lt;/em&gt; of being deep in a codebase. Writing code all by myself evokes sensations I don&amp;rsquo;t experience quite so much when working through an agent.&lt;/p&gt;
&lt;p&gt;So I&amp;rsquo;ve been wondering: what subjective changes are practitioners actually feeling?&lt;/p&gt;
&lt;p&gt;If we&amp;rsquo;re metaphorical woodworkers getting access to power tools, we&amp;rsquo;ll go faster for sure. But in our haste and adoption of this new role as agent managers, do we lose the smell of the wood and the feel of the grain? And I expect it&amp;rsquo;s not just a loss, but a trade; are there new feelings and sensations?&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m guessing this varies by person, but I haven&amp;rsquo;t seen it discussed much. So I took a personal inventory. Here&amp;rsquo;s my summary of my very subjective observations:&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Feeling of&amp;hellip;&lt;/th&gt;
					&lt;th&gt;Manual code&lt;/th&gt;
					&lt;th&gt;Agent-assisted code&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Place&lt;/td&gt;
					&lt;td&gt;Local&lt;/td&gt;
					&lt;td&gt;Tourist&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Texture&lt;/td&gt;
					&lt;td&gt;Sensitive&lt;/td&gt;
					&lt;td&gt;Tough&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Scale&lt;/td&gt;
					&lt;td&gt;Micro&lt;/td&gt;
					&lt;td&gt;Macro&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Time&lt;/td&gt;
					&lt;td&gt;Self-paced&lt;/td&gt;
					&lt;td&gt;Externally-paced&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Control&lt;/td&gt;
					&lt;td&gt;Ownership&lt;/td&gt;
					&lt;td&gt;Renting&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&#34;place-local-vs-visiting-tourist&#34;&gt;Place: local vs visiting tourist&lt;/h3&gt;
&lt;p&gt;When I&amp;rsquo;m working in one focused area of code, it starts to feel almost cozy. I recognize functions as they scroll past. I know the tooling. Everything is where I expect it.&lt;/p&gt;
&lt;p&gt;With an agent, I&amp;rsquo;m a tourist. The agent is a guide (which may not always be good). My view is out a window, not down at the workbench. I can travel within and between codebases much faster than before, and see things I wouldn&amp;rsquo;t have understood in the past. But I&amp;rsquo;m not really &lt;em&gt;there&lt;/em&gt;.&lt;/p&gt;
&lt;h3 id=&#34;texture-sensitive-vs-tough&#34;&gt;Texture: sensitive vs tough&lt;/h3&gt;
&lt;p&gt;Manual coding makes friction really feel like something. Bad APIs grate. Awkward abstractions are an itch you want to scratch. A misnamed variable sticks out to me until I rename it. The texture is right there, and often it&amp;rsquo;s discomfort driving me to make changes.&lt;/p&gt;
&lt;p&gt;With an agent, I don&amp;rsquo;t feel that. There&amp;rsquo;s no pain when I&amp;rsquo;m dealing with gross or bad stuff. Bad abstractions? Dependency hell? I just stomp over them. It&amp;rsquo;s like a mech suit: I have more leverage, more power, different sense of scale. I can move mountains. Sometimes to the wrong spot.&lt;/p&gt;
&lt;h3 id=&#34;scale-micro-vs-macro&#34;&gt;Scale: micro vs macro&lt;/h3&gt;
&lt;p&gt;Manual coding puts my hands on small units: lines, functions, variables. The work is granular and the seams are tactile and visible.&lt;/p&gt;
&lt;p&gt;With an agent, my hands are on bigger units: features, PRs, whole modules. There&amp;rsquo;s leverage in that, but the seams are less directly pronounced. It can become a big mass I&amp;rsquo;m squishing around like clay, rather than fine little lego bricks.&lt;/p&gt;
&lt;h3 id=&#34;time-self-vs-external&#34;&gt;Time: self vs external&lt;/h3&gt;
&lt;p&gt;There&amp;rsquo;s a state I sometimes fall into hand-coding where time goes soft. No streaming output to watch. No tool call to wait on. Just me and the code, whatever pace my thinking goes at.&lt;/p&gt;
&lt;p&gt;With an agent, the rhythm is a bit harder to control. I read, ask, then wait. Even when I&amp;rsquo;m steering, I&amp;rsquo;m steering between what feels like someone else&amp;rsquo;s bursts of work, when they look up. I notice myself getting impatient. I want to see the agent go &lt;em&gt;brrrr&lt;/em&gt;, tests pass, feature ship.&lt;/p&gt;
&lt;h3 id=&#34;control-own-vs-rent&#34;&gt;Control: own vs rent&lt;/h3&gt;
&lt;p&gt;When I&amp;rsquo;ve written something line by line, I remember it. Or, at least, there&amp;rsquo;s a chance I&amp;rsquo;ll remember it. I can look at a function from a few months ago, and while it may take me a minute or ten, eventually I might recall what it was like to write it, maybe why I named it that, or how the bug was making me feel, or how long I spent. I&amp;rsquo;m more rooted in the code, or it in me.&lt;/p&gt;
&lt;p&gt;When I&amp;rsquo;m using an agent, while I may read code, I don&amp;rsquo;t spend time agonizing over the details that burn the forms into my memory. And no matter how much I invest in my little harness skills or setups, it all feels a bit temporary. Some of that is the pace of change in the industry. But also I don&amp;rsquo;t own the models, not the big ones. And I don&amp;rsquo;t know what will change next month. No point making myself super at home.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;All of the above are intentionally exaggerated comparisons. Real behavior, at least with engineers I know, is some blend. You can use these tools in all sorts of ways, and I&amp;rsquo;m sure there are tricks to adopt the new tools with fewer compromises, or with different sensation profiles.&lt;/p&gt;
&lt;p&gt;I do notice writing code unassisted today feels quite slow and effortful. It&amp;rsquo;s downright uncomfortable contending with, say, a big React codebase without an agent there to buffer me from all the bundling, linting, and dependency sharp edges. It&amp;rsquo;s also not really justified to do unassisted in any practical projects that just need to ship a thing.&lt;/p&gt;
&lt;p&gt;So I find myself going agentless very rarely. Pretty much never. When I do, it&amp;rsquo;s harder to find the state I used to find easily.&lt;/p&gt;
&lt;p&gt;But I&amp;rsquo;ll keep trying. It&amp;rsquo;s healthy to force it now and then? Like choosing to walk for fifteen minutes when you could just drive for two. When I take those walks, I always see some new detail I would never have noticed from a car. And afterward I tend to feel more embodied and aware of the world.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>In praise of tinkering</title>
    <link href="https://brentfitzgerald.com/posts/in-praise-of-tinkering/" />
    <id>https://brentfitzgerald.com/posts/in-praise-of-tinkering/</id>
    <published>2025-11-07T20:36:00Z</published>
    <updated>2025-11-07T20:36:00Z</updated>
    <summary>Tinkering looks pointless from the outside. It isn&#39;t. It&#39;s how we follow interestingness, listen to intuition, and learn things we couldn&#39;t plan for.</summary>
    <content type="html">&lt;p&gt;If life does in fact flash before our eyes in our final moments&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a href=&#34;#fn:1&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt;, I&amp;rsquo;ll end up reviewing quite a few spans of activity where I was focused and engaged in what looks like pointless, meandering tinkering.&lt;/p&gt;
&lt;p&gt;And that&amp;rsquo;s fine. Because tinkering is not pointless at all! It&amp;rsquo;s deeply meaningful.&lt;/p&gt;
&lt;h2 id=&#34;what-is-tinkering&#34;&gt;What is tinkering?&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m defining tinkering as messing around with things primarily out of curiosity. Possibly there&amp;rsquo;s some stated objective, but there&amp;rsquo;s not much concern or urgency for achieving it. It&amp;rsquo;s nonlinear exploration without a required outcome. It&amp;rsquo;s about how things work, what&amp;rsquo;s possible, what&amp;rsquo;s interesting, what&amp;rsquo;s surprising. &lt;sup id=&#34;fnref:2&#34;&gt;&lt;a href=&#34;#fn:2&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;It often looks pointless from the outside. Sometimes feels pointless too. We fix something that wasn&amp;rsquo;t particularly broken, build something no one needs, take apart something and never put it back together quite right.&lt;/p&gt;
&lt;p&gt;Tinkering is definitionally not outcome or timeline oriented, so you can&amp;rsquo;t plan it well. There&amp;rsquo;s no roadmap, stakeholders, or milestones. If we have anything resembling a to-do list or plan, we might not be tinkering anymore (unless we&amp;rsquo;re tinkering with to-do lists, which a lot of us do).&lt;/p&gt;
&lt;p&gt;I know I&amp;rsquo;m not alone in feeling continuous cultural and professional pressure to optimize everything: time, space, work, relationships, habits, and my brain. So sometimes I feel some temptation to cast tinkering as a quiet rebellion. &lt;em&gt;What if I just ignored what&amp;rsquo;s &lt;em&gt;optimal&lt;/em&gt; and did what&amp;rsquo;s &lt;em&gt;interesting&lt;/em&gt;?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Unfortunately, when we start tinkering for any lofty reason other than fiddling with what&amp;rsquo;s in front of us, it starts getting ruined. If we look at it too hard, it withers. So the act of rebellion might be a side effect, but if we set out to achieve it, we&amp;rsquo;re doing it wrong.&lt;/p&gt;
&lt;p&gt;Tinkering is also certainly not about mastery, which requires ongoing discipline and dedication. Eventual mastery is another possible side effect; maybe we learn we are good at something by messing with it. But again, we don&amp;rsquo;t become masters by casually messing with things.&lt;/p&gt;
&lt;p&gt;And of course tinkering is not about predictable results. We set out to do &lt;em&gt;something&lt;/em&gt;, but that something might change along the way. And that&amp;rsquo;s fine. Or that something might get accomplished. Don&amp;rsquo;t count on it, but that would be nice.&lt;/p&gt;
&lt;h2 id=&#34;interestingness-over-optimization&#34;&gt;Interestingness over optimization&lt;/h2&gt;
&lt;p&gt;When we tinker, we aren&amp;rsquo;t going after the big meaningful questions. We&amp;rsquo;re dancing around with the trivial little ones. How might this hinge fit better? Is there a better way to set up this perfectly working spreadsheet? What if I increase the hydration of this dough a little more over last time?&lt;/p&gt;
&lt;p&gt;When bouncing around in these little questions, we&amp;rsquo;re actually making an indirect kind of progress on the big ones. Rather than following a plan, we&amp;rsquo;re listening to intuition, letting an under-exercised part of our brains set the course. And the heuristic is: &lt;em&gt;what&amp;rsquo;s interesting right now?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;And that&amp;rsquo;s it. We don&amp;rsquo;t need to stop to ask why (unless that&amp;rsquo;s interesting). It feels like a random walk at any given moment, but we&amp;rsquo;re not rolling the dice to determine the next step. It&amp;rsquo;s a choice. And over time, we gain an intuitive awareness of the shape of interestingness.&lt;sup id=&#34;fnref:3&#34;&gt;&lt;a href=&#34;#fn:3&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;So let&amp;rsquo;s momentarily fade the social consensus about what deserves attention and optimization. In the state of tinkering, our impulses and weird biases guide us. Over time, perhaps we learn by observing our behaviors, firming up a facet of self-awareness. Or maybe we just end up with an inedible loaf of bread or a branch of broken code we&amp;rsquo;ll never touch again. Either is fine.&lt;/p&gt;
&lt;p&gt;So if life really does flash before our eyes, while I&amp;rsquo;ll mostly want to see scenes of my kids, family, and friends, I won&amp;rsquo;t be upset when the reel lingers a bit on those aimless moments when nothing was at stake, moving from moment to moment, with curiosity as my guide.&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;Turns out our life may, in fact, &lt;a href=&#34;https://www.frontiersin.org/journals/aging-neuroscience/articles/10.3389/fnagi.2022.813531/full&#34;&gt;flash before our eyes in our final moments&lt;/a&gt;&amp;#160;&lt;a href=&#34;#fnref:1&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:2&#34;&gt;
&lt;p&gt;Lovely little post on the Exploratorium blog &lt;a href=&#34;https://www.exploratorium.edu/tinkering/blog/2016/02/24/tinkering-and-making-&#34;&gt;exploring tinkering in relation to making&lt;/a&gt;.&amp;#160;&lt;a href=&#34;#fnref:2&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:3&#34;&gt;
&lt;p&gt;For a deeper exploration of the heuristics of &lt;em&gt;interestingness&lt;/em&gt; or &lt;em&gt;novelty&lt;/em&gt;, I must recommend &lt;a href=&#34;https://www.goodreads.com/book/show/25670869-why-greatness-cannot-be-planned&#34;&gt;&lt;em&gt;Why Greatness Cannot Be Planned: The Myth of the Objective&lt;/em&gt;&lt;/a&gt; by Kenneth O. Stanley and Joel Lehman. Turns out there&amp;rsquo;s an actual science to getting better results by… not trying to get better results.&amp;#160;&lt;a href=&#34;#fnref:3&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <title>Light plans</title>
    <link href="https://brentfitzgerald.com/posts/light-plans/" />
    <id>https://brentfitzgerald.com/posts/light-plans/</id>
    <published>2025-11-04T08:00:00Z</published>
    <updated>2025-11-04T08:00:00Z</updated>
    <summary>Small ideas often lose their lift in planning. When someone floats one, resist the urge to grab on. Help it stay aloft.</summary>
    <content type="html">&lt;h2 id=&#34;small-light-plans-are-fun&#34;&gt;Small, light plans are fun&lt;/h2&gt;
&lt;p&gt;They are quick to start, and then painless to ditch if they fail. They can be so spontaneous and exciting that you forget you&amp;rsquo;re working. The lighter you keep them, the faster they can take shape, the quicker you learn.&lt;/p&gt;
&lt;p&gt;In certain environments they are even a little rebellious.&lt;/p&gt;
&lt;p&gt;But even in healthy, capable teams, the lightest ideas can get weighed down fast. Here&amp;rsquo;s the classic pile-on:&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;&lt;/th&gt;
					&lt;th&gt;&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Plan proposer:&lt;/td&gt;
					&lt;td&gt;&lt;em&gt;&amp;ldquo;I can put together a tiny first version so we can try this.&amp;rdquo;&lt;/em&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Well-meaning Person A:&lt;/td&gt;
					&lt;td&gt;&lt;em&gt;&amp;ldquo;Awesome idea, it&amp;rsquo;ll be great to see how that impacts adoption.&amp;rdquo;&lt;/em&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Well-meaning Person B:&lt;/td&gt;
					&lt;td&gt;&lt;em&gt;&amp;ldquo;Yes! And we should add it to the survey. Let me create a ticket.&amp;rdquo;&lt;/em&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;RIP fast fun mode. That thing you floated has people tugging at it. What was a mini-skunkworks now needs research, coordination, a few extra decisions.&lt;/p&gt;
&lt;h2 id=&#34;attention-is-weight&#34;&gt;Attention is weight&lt;/h2&gt;
&lt;p&gt;Unless you&amp;rsquo;re in a heinous politically toxic environment (in which case I&amp;rsquo;m sorry), the above is not sabotage. Humans just like to put our brains together to help. Maybe we like you and want to see you succeed. Or perhaps we grab on because we are interested in the promising idea; we want to help make it real.&lt;/p&gt;
&lt;p&gt;If contributors start weighing in, it&amp;rsquo;s typically a classic &amp;ldquo;good problem to have.&amp;rdquo; We like you and/or your idea. Each addition is a small, well-meaning gift. Our intentions are good, even if the result is inertia.&lt;/p&gt;
&lt;p&gt;The bigger the team, the harder this gets. Everyone&amp;rsquo;s job is to contribute. In a culture where everyone wants to add value and where &amp;ldquo;collaboration&amp;rdquo; is part of the culture, holding back feels rude. Ideas become group projects before they get off the ground. So light plans just don&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re trying to keep your plans light, a couple of things help.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Keep it diminutive.&lt;/strong&gt; &amp;ldquo;So alpha, you won&amp;rsquo;t even want to use it!&amp;rdquo; as one of my old lab mates said. This sets the parameters and gives others something to honor.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Be politely repetitive.&lt;/strong&gt; Thank your contributors, then bring the idea back to its small form. &amp;ldquo;Totes McGoats. Once we&amp;rsquo;re out of alpha that&amp;rsquo;s a great next step.&amp;rdquo;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That&amp;rsquo;s most of it. The rest comes down to restraint and willpower. These are fascinating topics worth exploring, but are a bit heavier than this cute little post.&lt;/p&gt;
&lt;p&gt;(See what I did there?)&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s not hard to spot this tendency in others. But really, it&amp;rsquo;s important to notice and catch that tug in ourselves. When we feel the urge to add the obvious next thing, consider: how could I be supportive without adding weight?&lt;/p&gt;
&lt;p&gt;So when we see a new idea, let&amp;rsquo;s resist the urge to grab on tight. Help it float a little longer.&lt;/p&gt;
</content>
  </entry>
</feed>
