<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Victor García</title>
    <description>The latest articles on DEV Community by Victor García (@micelclaw).</description>
    <link>https://dev.to/micelclaw</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3820897%2Fa121360b-8d01-406f-a889-9304625f2e47.png</url>
      <title>DEV Community: Victor García</title>
      <link>https://dev.to/micelclaw</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/micelclaw"/>
    <language>en</language>
    <item>
      <title>Your files, and the two questions a folder can't answer</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Thu, 20 Aug 2026 18:13:50 +0000</pubDate>
      <link>https://dev.to/micelclaw/your-files-and-the-two-questions-a-folder-cant-answer-25ia</link>
      <guid>https://dev.to/micelclaw/your-files-and-the-two-questions-a-folder-cant-answer-25ia</guid>
      <description>&lt;p&gt;A folder is a very old idea and a very good one. You put a thing somewhere, and later you look in that somewhere and the thing is there.&lt;/p&gt;

&lt;p&gt;What a folder has never been able to tell you is anything &lt;em&gt;about&lt;/em&gt; what's inside it. Two questions in particular, and they're the two that cost you real time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did I already save this somewhere else?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;What did this look like before I changed it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every file manager lets you answer those by hand. You go looking. You compare. You give up and keep both. This post is about the two screens that answer them for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE ORDINARY PART, FIRST
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsu6w57aofeedd1vt080d.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsu6w57aofeedd1vt080d.jpg" alt="The BoomClaw folder: contracts, finance, hr, insurance, legal, marketing, products, safety — plus loose files" width="799" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a real working drive — a small fireworks company, eight departments, a few loose files that never found a home. Nothing here needs explaining, which is the point. You can drag things around, make folders, upload, star, throw away.&lt;/p&gt;

&lt;p&gt;The storage counter in the corner says &lt;strong&gt;97.6 MB used&lt;/strong&gt;. It's one number, always visible, and it counts what's actually on the disk.&lt;/p&gt;

&lt;p&gt;The one thing worth noticing: the sunset photo shows a preview and the documents don't. Previews are made ahead of time, when the file lands, not while you wait for the folder to open. So a folder full of images opens as fast as a folder full of anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  QUESTION ONE: DID I ALREADY SAVE THIS?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwe5i3ly4vzj8krz1jmsr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwe5i3ly4vzj8krz1jmsr.jpg" alt="Duplicates: 5 groups, 380 KB recoverable — the same sunset photo in three places" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the whole feature in one screen. &lt;strong&gt;Five groups. 380 KB you can get back.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Look at the first one. The same photo of a sunset at the Malvarrosa beach lives in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/drive/Shows 2026/Malvarrosa sunset - site scouting.jpg&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/drive/BoomClaw/Malvarrosa sunset - site scouting.jpg&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/Photos/2026/07/beach-sunset-01.jpg&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two of them share a name. &lt;strong&gt;The third one doesn't.&lt;/strong&gt; It's the same photo — byte for byte, the same photo — sitting in the camera roll under the name the camera gave it, and no amount of looking through folders was ever going to connect it to the other two.&lt;/p&gt;

&lt;p&gt;That's the part that matters. The grouping isn't done by name, or by size, or by date. It reads the contents and gives each file a fingerprint; identical contents mean an identical fingerprint, whatever you called it or wherever you dropped it.&lt;/p&gt;

&lt;p&gt;And then it &lt;em&gt;doesn't&lt;/em&gt; do anything. Every copy has its own &lt;strong&gt;Keep this&lt;/strong&gt; button, and until you press one, nothing moves. That's deliberate. A deduplicator that decides for you is a deduplicator that eventually deletes the copy you needed — the one on the desktop you were about to email, or the one your accountant has a link to. The system is allowed to notice. You do the deciding.&lt;/p&gt;

&lt;p&gt;Two honest limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It catches identical files, not similar ones.&lt;/strong&gt; Re-export that sunset at a smaller size and it's a different file with a different fingerprint. Same picture to you; not to the fingerprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;380 KB is not going to change your life.&lt;/strong&gt; On this drive the duplicates are small. The value isn't the space — it's knowing that the three copies exist, so you stop wondering which one is current.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  QUESTION TWO: WHAT DID IT LOOK LIKE BEFORE?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzd7h733vll36m6of68l2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzd7h733vll36m6of68l2.jpg" alt="Version history: three snapshots of a product spec, with dates and sizes" width="720" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a product spec for a firework shell, and it has been through three revisions since June.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v1&lt;/strong&gt;, 14 June, 191 bytes — the first draft.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v2&lt;/strong&gt;, 28 June, 367 bytes — after the first test firing went wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v3&lt;/strong&gt;, 11 July, 436 bytes, labelled &lt;em&gt;"Before the safety review"&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one has three buttons: download it, put it back, throw it away.&lt;/p&gt;

&lt;p&gt;Two things are going on here, and they're different. &lt;strong&gt;v1 and v2 were taken automatically&lt;/strong&gt; — nobody asked for them. When you save over a file, the version that was there a second ago gets kept first, and then your new one takes its place. You don't have to remember to do anything, which is the only reason it works; the backup you have to remember to make is the backup you don't have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v3 was taken on purpose.&lt;/strong&gt; Someone was about to hand the document to a safety reviewer, pressed &lt;strong&gt;+ Snapshot&lt;/strong&gt;, and typed &lt;em&gt;"Before the safety review"&lt;/em&gt;. That's the difference between a version history and a useful version history: automatic snapshots tell you &lt;em&gt;when&lt;/em&gt;, and a note tells you &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;You can see where this goes. The reviewer comes back and says the minimum distance figure is wrong. You change it. Three weeks later somebody asks what the original measurement was, and the answer is one click away instead of a conversation.&lt;/p&gt;

&lt;p&gt;The honest gap: &lt;strong&gt;you can't compare two versions side by side yet.&lt;/strong&gt; You can download an old one and open it, or restore it and look, but there's no diff view. For a short document that's fine. For a long one it's the first thing you'll want, and it isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHY THESE TWO AND NOT TWENTY
&lt;/h2&gt;

&lt;p&gt;Both of these exist for the same reason, and it isn't tidiness.&lt;/p&gt;

&lt;p&gt;Every file you keep is a small bet that you'll be able to find it and trust it later. Duplicates break the trust — three copies and no idea which one is current. A missing history breaks it too — you changed something, you can't remember what it said, so now you're not sure the current version is right either.&lt;/p&gt;

&lt;p&gt;Neither problem shows up on the day you create it. Both show up eighteen months later, when you need the thing and you're standing in front of four files with similar names.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHAT'S NOT HERE
&lt;/h2&gt;

&lt;p&gt;Said plainly, because a features list that only lists wins isn't information:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No side-by-side compare&lt;/strong&gt; between versions, as above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version history is a Pro feature.&lt;/strong&gt; Snapshots take disk space, and there's a policy screen behind the gear icon to cap how many are kept per file — but it's not in the free tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Near-duplicate detection doesn't exist.&lt;/strong&gt; Only exact matches. The resized copy of your own photo is invisible to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The duplicate list doesn't suggest which one to keep.&lt;/strong&gt; It shows you the paths and the dates and stops. Deliberate, but it does mean you read five lines instead of pressing one button.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  THE TAKEAWAY
&lt;/h2&gt;

&lt;p&gt;Folders answer "where". They were never going to answer "again?" or "before?".&lt;/p&gt;

&lt;p&gt;Those two questions are what turn a drive full of files into a drive you can rely on — and they're the two you can't answer by looking harder, because the answer lives across folders and across time. So the system answers them, shows its work, and leaves the decision with you.&lt;/p&gt;

&lt;p&gt;Everything on your own hardware, as always. The fingerprints, the snapshots and the previews are all computed at home; nothing about your files goes anywhere.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: your photos. Type what you half-remember about one — the colour of a door, a city, someone in a hi-vis vest — and see if it finds it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>showdev</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Photograph a receipt, and your inventory fills itself in</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Thu, 06 Aug 2026 08:57:20 +0000</pubDate>
      <link>https://dev.to/micelclaw/photograph-a-receipt-and-your-inventory-fills-itself-in-22eb</link>
      <guid>https://dev.to/micelclaw/photograph-a-receipt-and-your-inventory-fills-itself-in-22eb</guid>
      <description>&lt;p&gt;Everyone who has ever started an inventory has abandoned it in week two. Not because it isn't useful — because keeping it current is a chore that pays off later and costs you now.&lt;/p&gt;

&lt;p&gt;So the interesting question isn't what the module can store. It's how little you have to do to keep it true.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE ANSWER TO "WHAT DO I HAVE?"
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdxndvk3lhf7ym0ocyu3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdxndvk3lhf7ym0ocyu3.jpg" alt="The inventory overview: totals, value by category, value by location" width="800" height="524"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four numbers, then two charts that answer the two questions people actually ask: &lt;strong&gt;what is it worth&lt;/strong&gt; and &lt;strong&gt;where is it&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The ring is value by category, with the total in the middle and the same data as a ranked list beside it — 54% of the value is one van, 16% is the firing system. The bars are value by location, so you can see that most of what you own is sitting in the rigging van and not in the warehouse you thought.&lt;/p&gt;

&lt;p&gt;The amber card counts what's below its minimum. Two items. That's the entire low-stock feature: you set a floor on the things you don't want to run out of, and this number is how you find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE PART THAT DOES THE WORK FOR YOU
&lt;/h2&gt;

&lt;p&gt;Underneath is a queue, and it's the reason the inventory stays current:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft7apacgy71fyrdj8s70u.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft7apacgy71fyrdj8s70u.jpg" alt="The review queue: four proposed items with Approve buttons" width="799" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four items proposed, each with its price, each with a tag saying where it came from — &lt;code&gt;RECEIPT&lt;/code&gt;. You didn't type any of them.&lt;/p&gt;

&lt;p&gt;You photographed a receipt, or pressed a button on an order confirmation in your mail. A vision model reads it and pulls out each product, how many, the unit price, a category, the reference code, the shop and the date. &lt;strong&gt;The model runs on your own machine&lt;/strong&gt; — the photo doesn't go to anyone's cloud, because there isn't one to go to.&lt;/p&gt;

&lt;p&gt;And then it stops. It proposes; it doesn't add. You get Approve, an edit pencil, and a reject cross, and you can fix the price before you accept it. That's deliberate: a small model reading a crumpled receipt will get a line wrong now and then, and the person who should catch it is you, in the two seconds it takes to glance at a list — not in six months when the numbers don't add up.&lt;/p&gt;

&lt;p&gt;Two small mercies that came out of using it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Photograph the same receipt twice and nothing duplicates.&lt;/strong&gt; Each proposed line gets a fingerprint, so re-analysing an email you already processed adds nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works out your warranty.&lt;/strong&gt; If what you bought looks like electronics, an appliance or a tool, it sets the expiry three years out — the EU legal minimum — from the purchase date it read off the paper. There's a panel that warns you when there are fewer than 90 days left, for things you only ever photographed a receipt for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  THE CATALOGUE
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe66f4r3f4bke6tq125zp.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe66f4r3f4bke6tq125zp.jpg" alt="The catalogue: name, category, location, quantity, value, warranty, tags, status" width="800" height="524"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One row per thing, and the columns are the questions: what is it, what kind, where is it, how many, what's it worth, when does the warranty end, and what state is it in.&lt;/p&gt;

&lt;p&gt;Quantities read &lt;code&gt;9.000/4.000&lt;/code&gt; — how many you have over the minimum you set. When the first number drops below the second it goes red, which is how "Mortar rack (12 tubes)" is showing &lt;code&gt;3.000/4.000&lt;/code&gt; in amber up there. No configuration, no alert rules to write. A number and a floor.&lt;/p&gt;

&lt;p&gt;Filters across the top for category, status, location and tag, and a search box. Services sit in the same list as physical things, with a dash where the quantity would be, because "Fireworks show (rigging + firing)" is something you sell but never stock.&lt;/p&gt;

&lt;h2&gt;
  
  
  NOTHING IS EVER DELETED
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fna27mm5u6dboosqsi0us.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fna27mm5u6dboosqsi0us.jpg" alt="The stock ledger, with every movement and its running balance" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every arrival, sale, loan, transfer and recount is a line here, and the quantity on the catalogue page is the sum of them. Not a number someone typed over — the sum.&lt;/p&gt;

&lt;p&gt;That means the history survives your mistakes. Correct a movement and you don't lose the original: a counter-movement appears next to it, and both stay. The button that does it says so on the tooltip, in as many words: &lt;em&gt;the ledger is never deleted&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;There's an &lt;code&gt;Undo&lt;/code&gt; on every row for exactly that, and a &lt;code&gt;Receive document&lt;/code&gt; button in the toolbar, which is the same receipt-reading trick pointed at a delivery note.&lt;/p&gt;

&lt;h2&gt;
  
  
  AND A SCANNER THAT ASKS FIRST
&lt;/h2&gt;

&lt;p&gt;The round button in the corner opens the camera, but not straight into a scan. It asks what you're doing first, with four options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Locate&lt;/strong&gt; — open whatever this is, or the bin it's in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count&lt;/strong&gt; — adjust the quantity, for a stocktake&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move to bin&lt;/strong&gt; — transfer it somewhere else&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check in / out&lt;/strong&gt; — return a loan, or lend it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which sounds like a small thing until you've used a scanner that guesses. Same barcode, four completely different intentions, and no way to undo the wrong one gracefully. Stick a QR label on a box, scan it, pick &lt;em&gt;Locate&lt;/em&gt;, and you get a list of what's inside without opening it.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHAT IT DOESN'T DO YET
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No printable QR labels for items yet&lt;/strong&gt; — only for locations. Boxes and shelves, yes; individual things, not yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No mobile stocktake mode.&lt;/strong&gt; You can count with the scanner one item at a time, but there's no "walk the shelf and tick things off" flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The loan reminder button is disabled.&lt;/strong&gt; You can lend something with a return date and see it go overdue, but the nudge isn't wired up yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serial numbers and batches aren't there.&lt;/strong&gt; Right now ten identical things are a quantity of ten, not ten tracked units.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  THE TAKEAWAY
&lt;/h2&gt;

&lt;p&gt;An inventory is only worth having if it's true, and it's only true if updating it is nearly free. Everything above is in service of that: a camera instead of a keyboard, a queue you approve instead of a form you fill in, a floor number instead of an alert rule, and a history that survives being wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The best inventory feature is the one that means you don't have to do the inventory.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: your photos. Type what you half-remember about one — the colour of a door, a city, someone's face — and see if it finds it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>showdev</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Three ways to look at your money. Pick the one that's you.</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Tue, 04 Aug 2026 15:38:18 +0000</pubDate>
      <link>https://dev.to/micelclaw/three-ways-to-look-at-your-money-pick-the-one-thats-you-32f2</link>
      <guid>https://dev.to/micelclaw/three-ways-to-look-at-your-money-pick-the-one-thats-you-32f2</guid>
      <description>&lt;p&gt;Most money software makes you a promise it can't keep: that one screen can serve a person tracking their spending, a freelancer chasing an unpaid invoice, and a company that has to file a VAT return. You end up with a tool that's too heavy for the first and too thin for the last.&lt;/p&gt;

&lt;p&gt;So the first thing you choose in Micelclaw isn't a report. It's who you are today.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmicelclaw.com%2Fimages%2Ffinance-lenses.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmicelclaw.com%2Fimages%2Ffinance-lenses.jpg" alt="The lens switcher: Personal, Invoicing, Accounting" width="794" height="49"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three lenses over exactly the same data. Nothing is duplicated, nothing is synced, and you never have to visit the one that isn't yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  PERSONAL — WHERE DID IT ACTUALLY GO
&lt;/h2&gt;

&lt;p&gt;This is the lens for a question everyone has and almost nobody can answer on demand: &lt;em&gt;am I better off than I was in January, and what am I spending it on?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmicelclaw.com%2Fimages%2Ffinance-personal.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmicelclaw.com%2Fimages%2Ffinance-personal.jpg" alt="The Personal lens: net worth, spending, savings rate, and the charts" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five numbers across the top, and each one has a subtitle telling you what it's made of, because a figure without its definition is just decoration. Net worth is everything you own minus everything you owe. Available balance is only the accounts you can actually spend from — the sum on the left doesn't include money that's owed to you, which is the single most common way people fool themselves.&lt;/p&gt;

&lt;p&gt;The savings rate says &lt;strong&gt;24%&lt;/strong&gt;, and underneath, in smaller type, &lt;em&gt;you save 6560,86 €&lt;/em&gt;. A percentage tells you how you're doing; the euros tell you what it bought. Both, always.&lt;/p&gt;

&lt;p&gt;Then three charts that answer three different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Net worth over time&lt;/strong&gt; — the shape of the last twelve months. Steps up are money arriving; the long flat stretches are the interesting part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expense by category&lt;/strong&gt; — a ring, and next to it the same thing as a ranked list with percentages and amounts. Salaries 51%, Utilities 14%, Insurance 10%. You can flip the whole panel to Income with one click and see where it comes from instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cash flow&lt;/strong&gt; — money in and money out, month by month, with the running balance drawn over the top.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Below that, your accounts with their real balances, and the most recent movements. Notice that some of those movements have a small grey label to the right — &lt;em&gt;Corporate tax payable&lt;/em&gt;, &lt;em&gt;Social security payable&lt;/em&gt;. That's the system telling you which account each one landed in, without you having to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  INVOICING — WHO OWES YOU, AND WHO ARE YOU AVOIDING
&lt;/h2&gt;

&lt;p&gt;The freelancer's lens. It exists because "how's business?" is really four questions, and they have four different answers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmicelclaw.com%2Fimages%2Ffinance-invoicing.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmicelclaw.com%2Fimages%2Ffinance-invoicing.jpg" alt="The Invoicing lens: invoiced, collected, pending, overdue" width="799" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invoiced&lt;/strong&gt; is what you've billed. &lt;strong&gt;Collected&lt;/strong&gt; is what actually arrived. &lt;strong&gt;Pending&lt;/strong&gt; is what's owed to you and still in date. &lt;strong&gt;Overdue&lt;/strong&gt; is the one to look at on a Monday morning. Four numbers, four colours, and the difference between the first two is the whole reason freelancers run out of money while "doing well".&lt;/p&gt;

&lt;p&gt;The same row repeats underneath for the other direction — what you've been billed, what you've paid, what you owe, what's late. And then the two figures that matter at the end of a quarter: the result for the period, spelled out as &lt;code&gt;29.222,33 € invoiced − 7541,98 € expenses&lt;/code&gt;, and the VAT you'll owe, spelled out as &lt;code&gt;charged 5113,30 € − paid 1112,48 €&lt;/code&gt;. No black boxes. Every headline number shows its arithmetic.&lt;/p&gt;

&lt;p&gt;Further down there's an ageing breakdown — how old your unpaid invoices are, in buckets, with the percentages — and your top customers as bars, so you can see at a glance whether one client is quietly becoming your whole business.&lt;/p&gt;

&lt;p&gt;The invoice list itself is deliberately boring, which is the point:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbqtalkmkr766umzxdbb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbqtalkmkr766umzxdbb.jpg" alt="The invoice list with status chips" width="799" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Number, customer, issued, due, total, status. Sent, Paid, Overdue, Draft. A draft has no number yet — it shows a dash — because numbering an invoice you might delete is how you end up with gaps you have to explain later.&lt;/p&gt;

&lt;p&gt;Click a customer and you get their whole file on one page:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcoa0jtzvsxxfgg999qdm.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcoa0jtzvsxxfgg999qdm.jpg" alt="A customer record with their details and documents" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tax ID, currency, payment terms, email, address, and every document you've exchanged with them, each with its status and amount. The same list holds customers and suppliers, filtered by a chip at the top, because in real life the same company is often both. And there's an &lt;strong&gt;Open in CRM&lt;/strong&gt; button, since a customer here is the same contact as everywhere else on your server — not a second copy that drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  ACCOUNTING — THE PART YOU HAND TO SOMEONE ELSE
&lt;/h2&gt;

&lt;p&gt;The third lens is the one most people will open twice a year, and that's fine. It's there so that when your accountant asks for something, the answer is a click and not an evening.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmicelclaw.com%2Fimages%2Ffinance-accounting.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmicelclaw.com%2Fimages%2Ffinance-accounting.jpg" alt="The Accounting lens: result, cash, VAT, net worth, and quick actions" width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four figures, a profit-and-loss summary, and four shortcuts to the things an accountant actually names: the journal, the chart of accounts, the tax forms. And in the corner, quietly, a line that says &lt;strong&gt;The books balance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That line is doing a lot of work. Underneath all three lenses is real double-entry bookkeeping — the same method your accountant uses, with a Spanish chart of accounts, VAT rates and withholding built in. You never see any of it from the Personal lens. You just get a sentence confirming that everything you did this month added up.&lt;/p&gt;

&lt;p&gt;If you &lt;em&gt;do&lt;/em&gt; want to look, the trial balance is right there:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1xuvn2l989zw2hc14f3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1xuvn2l989zw2hc14f3.jpg" alt="The trial balance, with debit and credit columns" width="799" height="519"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here's a detail worth pausing on, because it's the kind of thing that separates a real ledger from a spreadsheet with ambitions. Look at the account codes: &lt;code&gt;400&lt;/code&gt; appears five times, once per supplier. &lt;code&gt;430&lt;/code&gt; appears once per customer. That's not a bug. In proper bookkeeping those codes are &lt;em&gt;categories&lt;/em&gt;, not identities — every customer gets their own receivable account, all filed under 430. It means you can ask "how much does Marina Costa Brava owe me?" and "how much is owed to me in total?" and both are one query.&lt;/p&gt;

&lt;h2&gt;
  
  
  ONE BOX, SEVERAL COMPANIES
&lt;/h2&gt;

&lt;p&gt;There's a small dropdown next to the module name that says &lt;strong&gt;Principal&lt;/strong&gt;. That's the profile switcher.&lt;/p&gt;

&lt;p&gt;If you run more than one thing — a company and your own freelance work, or two businesses — they live in the same box and one click apart, with completely separate books, invoices, customers and tax numbers. Nothing leaks between them, and you don't need a second server or a second subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHAT IT DOESN'T DO YET
&lt;/h2&gt;

&lt;p&gt;Being straight about the edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No electronic invoicing yet.&lt;/strong&gt; Spain is moving towards mandatory structured e-invoices (Facturae, and reporting straight to the tax agency). We generate PDFs, not signed XML. That's the biggest gap and we know it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tax forms are calculations, not filings.&lt;/strong&gt; It works out your quarterly VAT and your withholdings and shows you the numbers. It does not submit anything, and you can't yet save a snapshot of a form as you filed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The quarterly income-tax instalment is a simple version.&lt;/strong&gt; It applies the flat rate to income minus expenses. It's not a personal tax return and doesn't pretend to be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bank connections are import, not sync.&lt;/strong&gt; You bring in a CSV or an OFX file from your bank and match it against your books. There's no live feed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  THE TAKEAWAY
&lt;/h2&gt;

&lt;p&gt;The reason this took three lenses instead of one is that the alternative is a screen full of things you have to learn to ignore.&lt;/p&gt;

&lt;p&gt;A person shouldn't have to know what a chart of accounts is to find out where their money went. A company shouldn't have to lose the general ledger because the interface was simplified for someone else. So: same data underneath, three doors in, and the door you don't need stays shut.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You should be able to answer "how am I doing?" without first learning accounting.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: the same idea, pointed at your things instead of your money — and the bit where you photograph a receipt and your inventory fills itself in.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>showdev</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A 45% cut, and two things I was wrong about</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Tue, 04 Aug 2026 09:07:14 +0000</pubDate>
      <link>https://dev.to/micelclaw/a-45-cut-and-two-things-i-was-wrong-about-27hj</link>
      <guid>https://dev.to/micelclaw/a-45-cut-and-two-things-i-was-wrong-about-27hj</guid>
      <description>&lt;p&gt;Atlas is the agent that handles files, photos, office documents and projects. It runs on &lt;code&gt;gemma-4-12b&lt;/code&gt; with an 80,000-token context window, locally, on the same box as everything else. One afternoon it stopped fitting.&lt;/p&gt;

&lt;p&gt;Not "gave worse answers." Stopped fitting. The tool schemas of the apps it had been given were, on their own, larger than the room the model had to think in.&lt;/p&gt;

&lt;p&gt;Back in March I wrote &lt;a href="https://dev.to/blog/agent-token-waste"&gt;#07 — Your AI agent is wasting 90% of its context&lt;/a&gt;, about how much of an agent's window goes to describing tools it will never call. This is the sequel, four months later, with real measurements instead of estimates. It's also a retraction: two of the things I was most confident about turned out to be wrong, and a bug I'd diagnosed turned out not to exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE MEASUREMENT THAT STARTED IT
&lt;/h2&gt;

&lt;p&gt;Our agents reach the backend through a facade that exposes every domain as a typed tool (&lt;a href="https://dev.to/blog/mcp-facade"&gt;#13&lt;/a&gt; has the architecture). &lt;code&gt;tools/list&lt;/code&gt; returns the catalogue. Here's what it weighed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tools/list → 508,782 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Half a megabyte of JSON schema, for 409 tools. The interesting question isn't "that's a lot" — it's &lt;strong&gt;what exactly is in there&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I counted. Two fields dominated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;AUTH_TOKEN_FIELD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;CONVERSATION_ID_FIELD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tool declares both, because the facade needs the caller's identity and the conversation it belongs to. Their descriptions, multiplied by 409 tools, came to roughly &lt;strong&gt;133,000 tokens of the catalogue&lt;/strong&gt;. Descriptions of two fields that the model &lt;strong&gt;never fills in&lt;/strong&gt; — since &lt;a href="https://dev.to/blog/agent-identity"&gt;#14&lt;/a&gt;, a host-side hook injects the token before the call ever reaches the server.&lt;/p&gt;

&lt;p&gt;The second offender was a boilerplate paragraph in every tool description — 211 characters explaining how to pass the auth token — repeated 408 times. Once is instruction. 408 times is furniture.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE FIX WAS SUBTRACTION
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;T1&lt;/strong&gt;: empty the &lt;code&gt;.describe()&lt;/code&gt; on both identity fields. They stay &lt;em&gt;declared&lt;/em&gt; — this matters, and I'll come back to it — but they carry no prose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;T3&lt;/strong&gt;: strip the boilerplate from 408 descriptions and put it exactly once, in the agent's &lt;code&gt;TOOLS.md&lt;/code&gt;, where it belongs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Token: the auth_token of every claw_* tool is injected AUTOMATICALLY (host-side identity hook).
  DON'T fill it in yourself — if you do, it's ignored.
- Params: build each tool's parameters ONLY from the user's CURRENT request;
  do NOT reuse or copy the ones from a previous call.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result, verified live:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;508,782 B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;278,533 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;boilerplate occurrences&lt;/td&gt;
&lt;td&gt;408&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;auth_token&lt;/code&gt; still declared&lt;/td&gt;
&lt;td&gt;409/409&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;409/409&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;capability lost&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;−45.3%&lt;/strong&gt;, and every tool still executes. The whole change is deletion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the fields stay declared
&lt;/h3&gt;

&lt;p&gt;The MCP SDK validates tool arguments with a zod object in &lt;strong&gt;strip mode&lt;/strong&gt;: the handler receives the parsed args, and &lt;strong&gt;a field that isn't declared is discarded before it arrives&lt;/strong&gt;. If we removed &lt;code&gt;auth_token&lt;/code&gt; from the schema to save the last few bytes, the host-side injection would write into a field that the SDK then throws away — and every agent would stop authenticating at the same moment.&lt;/p&gt;

&lt;p&gt;That's why the remaining optimisation is deferred rather than shipped. Removing &lt;code&gt;$schema&lt;/code&gt; and omitting the two identity fields from the &lt;em&gt;announced&lt;/em&gt; schema (while keeping them in the parsed one) is worth roughly &lt;strong&gt;25,000 tokens of context per agent&lt;/strong&gt; — and requires bridging past the SDK's convenience wrapper. It's a separate change with its own verification, because the failure mode is "everything breaks at once."&lt;/p&gt;

&lt;h2&gt;
  
  
  THE FIRST THING I WAS WRONG ABOUT
&lt;/h2&gt;

&lt;p&gt;Here's what I believed when I started: &lt;strong&gt;408 tools were leaking into the model's context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The arithmetic seemed to support it. Half a megabyte of catalogue, divide by ~3.6 characters per token, and you get a number that looks catastrophic.&lt;/p&gt;

&lt;p&gt;It's a false positive, and the error is embarrassing once you see it. Dense JSON doesn't tokenise at 3.6 characters per token — it's closer to &lt;strong&gt;1.3&lt;/strong&gt;, because every brace, quote and colon is its own token. At that ratio, 408 tools would be ~360,000 tokens, which is more than the context window of the models involved. The number was impossible, and I'd written it down anyway.&lt;/p&gt;

&lt;p&gt;What was actually happening: &lt;strong&gt;the model received about 65 tools.&lt;/strong&gt; The runtime's per-agent deny-list was working correctly the whole time.&lt;/p&gt;

&lt;p&gt;The way to check is not arithmetic. It's the trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;~/.openclaw/agents/&amp;lt;agent&amp;gt;/sessions/&amp;lt;id&amp;gt;.trajectory.jsonl&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;context.compiled&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;data.tools&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;EXACT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;array&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;model&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That file has the answer, exactly, with no estimation. I had spent a day reasoning about a number I could have read.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE SECOND THING I WAS WRONG ABOUT
&lt;/h2&gt;

&lt;p&gt;I had also assumed the facade's &lt;code&gt;tools/list&lt;/code&gt; was filtering by identity — that when an agent asked for the catalogue, it got &lt;em&gt;its&lt;/em&gt; catalogue.&lt;/p&gt;

&lt;p&gt;It doesn't. &lt;code&gt;tools/list&lt;/code&gt; returns everything. The per-agent trimming is done &lt;strong&gt;100% by the runtime&lt;/strong&gt;, via a deny-list of tool names (&lt;code&gt;claw-os__&amp;lt;tool&amp;gt;&lt;/code&gt;). The facade is not the enforcement point; it never was.&lt;/p&gt;

&lt;p&gt;This matters because it changes where a fix has to live. Any per-agent trimming we want has to be expressed as entries in that deny-list — which is exactly what the next part does.&lt;/p&gt;

&lt;h2&gt;
  
  
  CORE AND FULL: THE MACHINERY WAS ALREADY THERE
&lt;/h2&gt;

&lt;p&gt;Every capability in our registry can be tagged &lt;code&gt;tier: 'core' | 'full'&lt;/code&gt;. The plumbing to compute a per-agent deny-list from those tiers existed too. It had been built months earlier and then left almost unused: only two domains tagged anything as &lt;code&gt;full&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the work wasn't building a mechanism. It was &lt;strong&gt;making a judgement call, 123 times&lt;/strong&gt;: for each advanced capability, is this everyday or is this rare?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;domain&lt;/th&gt;
&lt;th&gt;capabilities&lt;/th&gt;
&lt;th&gt;tagged &lt;code&gt;full&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;everyday core&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;finance&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;inventory&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;projects&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;files&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The criterion: core is CRUD plus the essential reads. Full is the advanced and the rare. Per-domain, core is &lt;strong&gt;57–65% lighter&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And then a detail that pays for the whole exercise: &lt;strong&gt;the tiers had been inert.&lt;/strong&gt; The filter only applies to domains exposed as one-tool-per-action, and those domains hadn't been migrated yet. Tags existed, meant nothing, and nobody noticed — including a &lt;code&gt;docker.inspect_container&lt;/code&gt; that had been tagged &lt;code&gt;full&lt;/code&gt; specifically to keep a raw dump out of core mode. Migrating the domains &lt;strong&gt;activated a security decision that had been sitting dormant&lt;/strong&gt;, without writing another line for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The number that reordered the plan
&lt;/h3&gt;

&lt;p&gt;My intuition was: migrate the infrastructure domains to per-action tools, and &lt;em&gt;while we're at it&lt;/em&gt;, split core from full. Measured, it's the other way round.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;tools&lt;/th&gt;
&lt;th&gt;catalogue bytes&lt;/th&gt;
&lt;th&gt;vs before&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before (6 fat tools)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;32,445&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Core&lt;/strong&gt; (the new default)&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;32,938&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+1.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full (asked for on demand)&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;48,161&lt;/td&gt;
&lt;td&gt;+48%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The six infrastructure domains hold 88 capabilities between them. Migrating them to one tool per action &lt;strong&gt;with full mode as the default&lt;/strong&gt; would have grown the catalogue by 48% — a straight regression. In core mode, 50 tools cost &lt;strong&gt;the same&lt;/strong&gt; as the 6 fat ones did, and the model no longer has to guess its way through a 34-value action enum.&lt;/p&gt;

&lt;p&gt;For the infrastructure agent — gemma, 80k window — that's &lt;strong&gt;+1,083 tokens instead of +12,793.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the split isn't an optional optimisation layered on the migration. It's what makes the migration survivable. The comment now sits in the code where the default lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; * The six INFRASTRUCTURE manuals are the exception, and the reason is measured […]
 * In core they're 49 tools and cost the SAME as the six fat tools before (−0.1%) —
 * with the bonus that the 34-action enum the model had to guess through disappears.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  One default, one place
&lt;/h3&gt;

&lt;p&gt;That default used to live copied in three separate files: the visibility calculation, the &lt;code&gt;TOOLS.md&lt;/code&gt; generator, and the context-budget estimator. Now there's one function, with the reason attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; * Effective mode of ONE skill. Single source: the default lived copied in three
 * places (visibility, TOOLS.md and context budget) and with copies it's a matter
 * of time before the catalogue says one thing and the deny-list says another.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent whose catalogue and whose deny-list disagree is a bug that presents as "the model hallucinated a tool."&lt;/p&gt;

&lt;h2&gt;
  
  
  ASKING FOR MORE, TEMPORARILY
&lt;/h2&gt;

&lt;p&gt;Core mode raises an obvious question: what happens when an agent genuinely needs an advanced capability?&lt;/p&gt;

&lt;p&gt;It asks. There's a capability for it (&lt;code&gt;skills.elevate&lt;/code&gt;, approval level 2 — you get a card), and on approval the app moves to full mode and the deny-list is re-synced, so the complete tools enter the agent's context &lt;strong&gt;on its next turn&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The elevation is &lt;strong&gt;ephemeral&lt;/strong&gt;, and that decision is worth more than the feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The elevation is ephemeral, "until the session closes" […] with NO new table&lt;/span&gt;
&lt;span class="c1"&gt;// and no new cron.&lt;/span&gt;
&lt;span class="c1"&gt;// Gotcha: a Core restart loses this registry → the app stays 'full'&lt;/span&gt;
&lt;span class="c1"&gt;// (which is the system DEFAULT, harmless).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An in-memory registry keyed by conversation, reverted when the conversation is deleted. No migration, no scheduled job, and a failure mode that degrades to the pre-existing default instead of to something novel. Verified against the database and the runtime: core (33 tools denied) → elevate → full (0 denied).&lt;/p&gt;

&lt;h2&gt;
  
  
  THE BAR THAT MADE IT VISIBLE
&lt;/h2&gt;

&lt;p&gt;None of the above is usable without an answer to "how full is this agent right now?" So: an endpoint, and a bar in the dashboard, showing base + system prompt + apps + free space against the model's window.&lt;/p&gt;

&lt;p&gt;The most important thing about it is what it refuses to do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// It's ALL ESTIMATES: there's no tokenizer, so we use the ratios already established&lt;/span&gt;
&lt;span class="c1"&gt;// […] (prose /4) and the JSON tool schema (/1.3, denser).&lt;/span&gt;
&lt;span class="c1"&gt;// Fail-soft by construction: a missing workspace file counts 0; if the model's window&lt;/span&gt;
&lt;span class="c1"&gt;// can't be resolved, model_window stays null (it is NOT invented).&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHARS_PER_TOKEN_PROSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHARS_PER_TOKEN_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CONTEXT_BUDGET_BASE_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the base:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; * Calibrated estimate of OpenClaw's "base" prompt + its NATIVE tool definitions […]
 * NOT measurable server-side: that text is injected by the runtime binary, not in any
 * file Core reads. Exposed as a CONSTANT and marked source:'estimate'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the dashboard that segment is drawn &lt;strong&gt;striped&lt;/strong&gt;, so it looks like an estimate. A number that can't be measured shouldn't render identically to one that can — that's the difference between a budget and a guess with a progress bar.&lt;/p&gt;

&lt;p&gt;The first agent we pointed it at was Francis, our coordinator, with nine apps in full mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;91,000 / 80,000 tokens  ·  −11,000 over budget
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Red. The offenders were finance (27k), projects (15k) and files (13k) — the exact three domains where core mode is worth the most.&lt;/p&gt;

&lt;p&gt;That reading was taken before the catalogue cut landed. Pointing the same bar at the same agent today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;78,000 / 80,000 tokens  ·  97%  ·  free: 2,300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still uncomfortably close to the ceiling — nine apps is a lot — but it now &lt;em&gt;fits&lt;/em&gt;, which it didn't. The apps that remain expensive are the ones still defaulting to full mode: photos (15k), mail (8.6k), calendar (7.6k).&lt;/p&gt;

&lt;h2&gt;
  
  
  THE BUG THAT DIDN'T EXIST
&lt;/h2&gt;

&lt;p&gt;One more retraction, and this is my favourite.&lt;/p&gt;

&lt;p&gt;While profiling, I noticed that gemma reported &lt;code&gt;usage.input&lt;/code&gt; roughly double the previous call's usage, over and over. I diagnosed a phantom retry: something was silently re-sending the prompt, and we were paying twice for every turn.&lt;/p&gt;

&lt;p&gt;Then I checked the correlation instead of trusting the story. Across a snapshot of turns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ratio ≈ 2.0 ⇔ &lt;strong&gt;there was a tool call&lt;/strong&gt;: 11 out of 11&lt;/li&gt;
&lt;li&gt;no tool call: ratio 1.0, every time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no phantom retry. That's the &lt;strong&gt;normal shape of a tool turn&lt;/strong&gt;: call one emits the tool call, call two reads the result and answers. &lt;code&gt;usage.input&lt;/code&gt; is the sum of both. The system was working exactly as designed and I'd written a bug report about it.&lt;/p&gt;

&lt;p&gt;The reframe is better than the bug would have been: since every prompt token is prefilled &lt;strong&gt;twice&lt;/strong&gt; on a tool turn — and the local runtime doesn't reuse its KV cache between the two calls (&lt;code&gt;cacheRead: 0&lt;/code&gt;) — &lt;strong&gt;every token we removed from the catalogue is worth double.&lt;/strong&gt; The 45% cut isn't 45% of one prefill, it's 45% of two.&lt;/p&gt;

&lt;p&gt;Which points at the next lever, and it isn't ours: getting the local runtime to reuse the KV prefix between those two calls. Same prompt, same tools, back to back.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHAT I'D DO DIFFERENTLY
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read the trace before doing the arithmetic.&lt;/strong&gt; Both of my wrong conclusions came from estimating something that was written down exactly, in a file I already had. Estimation is for things you can't observe. The tool array sent to the model is observable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check correlation before writing the bug report.&lt;/strong&gt; "usage doubles" was a real observation with a wrong explanation. Eleven data points took ten minutes and dissolved a bug I'd have spent days chasing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look for mechanisms that exist and are inert before building new ones.&lt;/strong&gt; The tier system was fully built and doing nothing. If I'd designed a fresh one, I'd have shipped a second mechanism &lt;em&gt;and&lt;/em&gt; left the dormant one to confuse the next person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make estimates look like estimates.&lt;/strong&gt; The striped segment is four lines of CSS and it's the reason nobody will ever quote the base number as a measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  HONEST CAVEATS
&lt;/h2&gt;

&lt;p&gt;The 45% cut is verified live. The tiers are verified against the database and the runtime. The elevation cycle is verified at the database and runtime level — &lt;strong&gt;not&lt;/strong&gt; end-to-end in a real chat conversation, which is a different thing and I'm not going to claim it. Non-infrastructure apps still default to full; only the six infrastructure ones default to core. And there's no measurement here in euros or in latency: this post is about bytes and estimated tokens, which is what we could actually measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE TAKEAWAY
&lt;/h2&gt;

&lt;p&gt;The engineering was subtraction: two empty &lt;code&gt;.describe()&lt;/code&gt; calls and one paragraph moved to where it belonged, for 45% of the catalogue.&lt;/p&gt;

&lt;p&gt;The expensive part was retracting two confident conclusions and one bug that didn't exist — all three of which came from reasoning about numbers I could have read directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a number matters enough to act on, find where the system writes it down.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: the seven agents from &lt;a href="https://dev.to/blog/multi-agent-topology"&gt;#15&lt;/a&gt; now fit in the window. What happens when you ask them to work on the same thing at the same time?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>optimization</category>
      <category>devops</category>
    </item>
    <item>
      <title>Seven agents per user: delegation, and every way it broke</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:42:55 +0000</pubDate>
      <link>https://dev.to/micelclaw/seven-agents-per-user-delegation-and-every-way-it-broke-5g1m</link>
      <guid>https://dev.to/micelclaw/seven-agents-per-user-delegation-and-every-way-it-broke-5g1m</guid>
      <description>&lt;p&gt;Here's a bug report we got from our own system, and it took us longer to understand than we'd like to admit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Ask Francis to generate a voice recording. He says he'll delegate it to Dali. Then nothing happens."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing happened. No error, no timeout, no failed tool call. The coordinator agent said it was handing the job to the specialist, and then the conversation just… stopped. Dali never spoke.&lt;/p&gt;

&lt;p&gt;We built a seven-agent topology because one agent with sixty tools is worse than seven agents with ten each. That part turned out to be right. What we underestimated is that &lt;strong&gt;delegation is the whole product&lt;/strong&gt;. Every interesting failure in the last four months has been at the seam between two agents, not inside one.&lt;/p&gt;

&lt;p&gt;This is a tour of that seam, including the parts still broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE TOPOLOGY
&lt;/h2&gt;

&lt;p&gt;Every user gets seven agents, provisioned atomically when their account is created. They are not shared. They are not a pool. Here's the actual configuration, straight out of &lt;code&gt;auto-provision.service.ts&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Scopes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Francis&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Coordinator — notes, calendar, mail, contacts, search, diary, bookmarks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-sonnet-4-6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notes:* events:* emails:* contacts:* diary:* bookmarks:* search:read graph:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Atlas&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Productivity — files, photos, office docs, projects, diagrams&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-sonnet-4-6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;files:* photos:* search:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sentinel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Infrastructure — storage, network, reverse proxy, DNS, VPN, Docker&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deepseek-chat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;storage:* hal:* proxy:* dns:* vpn:* docker:*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dali&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Creative — image generation, media, voice (TTS/STT), web browsing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-sonnet-4-6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;audio:* media:* photos:read files:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ledger&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Money — bank accounts, transactions, budgets, CRM&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deepseek-chat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;finance:* crm:* search:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Darwin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Intelligence — patterns, preferences, cross-domain search&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-sonnet-4-6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;search:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HASS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Smart home — IoT devices, automations, scenes, sensors&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deepseek-chat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ha:*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things worth pointing out, because they're the parts people get wrong when they copy this shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The models are deliberately different.&lt;/strong&gt; Sonnet for the agents that need judgment (coordination, creative work, pattern-finding); DeepSeek for the agents that mostly run deterministic infrastructure and finance operations. This isn't cost optimization dressed up as architecture — Sentinel restarting a container doesn't need the same reasoning as Francis deciding &lt;em&gt;which&lt;/em&gt; specialist should handle a request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The topology is flat.&lt;/strong&gt; Depth-1, siblings only, no nested sub-agents. Francis is the chief but not a bottleneck: you can talk to any agent directly from the chat. The coordinator exists to route the requests where you &lt;em&gt;didn't&lt;/em&gt; say who should handle it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two of them are nearly empty now, and we left it that way.&lt;/strong&gt; Darwin ships with exactly one skill (&lt;code&gt;claw-search&lt;/code&gt;). There's a comment in the config explaining why: &lt;code&gt;claw-graph&lt;/code&gt; and &lt;code&gt;claw-insights&lt;/code&gt; were retired in June because the knowledge graph and the derived insights turned out to be &lt;em&gt;human&lt;/em&gt; features of the dashboard, consumed through REST, not something an agent needed to poke at. Ledger is in the same shape. The honest version of a multi-agent design is that some agents shrink after you watch what people actually ask for.&lt;/p&gt;

&lt;p&gt;There's also a hidden eighth agent, &lt;code&gt;crawl&lt;/code&gt;, dedicated to the browser extension. It's marked &lt;code&gt;hidden: true&lt;/code&gt; and never shows up in the agents module — it exists so that the extension's chat doesn't compete with Francis for attention in the user's real browser session.&lt;/p&gt;

&lt;h3&gt;
  
  
  Namespacing, or: why every agent has a double dash in its name
&lt;/h3&gt;

&lt;p&gt;Agents are per-user, and the runtime that executes them is shared. So the identifier of an agent is &lt;code&gt;{prefix}--{name}&lt;/code&gt; — &lt;code&gt;paco--atlas&lt;/code&gt;, &lt;code&gt;admin--francis&lt;/code&gt;. The prefix is the local part of the user's email, normalized to &lt;code&gt;[a-z0-9-]&lt;/code&gt;, with a numeric suffix on collision, and it lives in &lt;code&gt;users.agent_prefix&lt;/code&gt; as &lt;code&gt;UNIQUE NOT NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each agent-user pair gets its own workspace directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.openclaw/workspaces/paco--atlas/
├── SOUL.md      # personality (atlas = methodical, dali = creative...)
├── USER.md      # user profile, propagated from the chief on bootstrap
├── TOOLS.md     # the catalog of skills this agent can reach
├── MEMORY.md    # persistent memory
└── skills/      # physical copies of the SKILL.md files it's allowed to read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The display name stays "Atlas". The namespacing is invisible to the user — right up until it isn't, which we'll get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAILURE 1: THE CHILD THAT INHERITED THE WRONG TOOLS
&lt;/h2&gt;

&lt;p&gt;The runtime gives an agent two ways to start work somewhere else. &lt;code&gt;sessions_spawn&lt;/code&gt; creates a child. &lt;code&gt;sessions_send&lt;/code&gt; hands a message to another agent's session and waits for the reply.&lt;/p&gt;

&lt;p&gt;We used &lt;code&gt;sessions_spawn&lt;/code&gt; first, because "spawn a Dali to do the audio" reads like exactly what you want. It isn't. A spawned child &lt;strong&gt;inherits the parent's tools&lt;/strong&gt;, not the target agent's. So &lt;code&gt;sessions_spawn(agentId: "paco--dali")&lt;/code&gt; produces something that is named Dali, is addressed as Dali, and does not have &lt;code&gt;claw_audio&lt;/code&gt;, because Francis doesn't have &lt;code&gt;claw_audio&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The delegation guide now says it in a box, in capitals, at the top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sessions_spawn creates a subagent that INHERITS YOUR tools, not the specialist's.
If you do sessions_spawn(agentId: "&amp;lt;PREFIX&amp;gt;--dali", ...), that child "Dali" ends up
WITHOUT claw_audio (because you don't have it) and the task fails.
sessions_spawn is only for YOUR OWN sub-tasks (a worker of yours running in parallel
with your same tools). To delegate to a specialist, ALWAYS sessions_send.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ask Dali for audio, Atlas for files, Sentinel for infra&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sessions_send&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The specialist runs as &lt;em&gt;itself&lt;/em&gt;, with &lt;em&gt;its&lt;/em&gt; tools (synchronous, you wait for the reply)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a sub-task of your own in parallel&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sessions_spawn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;It's your worker; inheriting your tools is correct here&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second-order lesson is more useful than the fix: &lt;strong&gt;when two operations differ only in whose capabilities they carry, a model will pick the wrong one.&lt;/strong&gt; The names don't encode the difference. Only the documentation does, and documentation is a prompt, and prompts get compacted.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAILURE 2: AN OPTIONAL PARAMETER WITH NO DESCRIPTION
&lt;/h2&gt;

&lt;p&gt;This is the one that produced the bug report at the top, and it is still open.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sessions_spawn&lt;/code&gt; takes an &lt;code&gt;agentId&lt;/code&gt;. In the runtime's schema, that parameter is declared as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional. And — this is the part that matters — &lt;strong&gt;with no &lt;code&gt;description&lt;/code&gt;&lt;/strong&gt;. Meanwhile &lt;code&gt;taskName&lt;/code&gt; and &lt;code&gt;thread&lt;/code&gt; both have descriptions. The tool's own summary is one line: &lt;em&gt;"Spawn subagent or ACP session."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Put yourself in the model's position. You get a tool with several described parameters and one undescribed optional one. You fill in what's described: &lt;code&gt;task&lt;/code&gt;, &lt;code&gt;taskName&lt;/code&gt;, &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;runtime&lt;/code&gt;, &lt;code&gt;sandbox&lt;/code&gt;, &lt;code&gt;mode&lt;/code&gt;. You omit the one nobody explained. And because it's optional, the runtime doesn't complain — &lt;strong&gt;it spawns you&lt;/strong&gt;. Francis delegates to Francis, does the work badly or not at all, and reports back that the specialist has been notified.&lt;/p&gt;

&lt;p&gt;We verified this against a real trace (session &lt;code&gt;ea2c4895&lt;/code&gt;): the call carried seven parameters and no &lt;code&gt;agentId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's the same class of bug as another one we hit in the MCP facade — a parameter the model can't fill because the schema never told it the parameter was load-bearing. A tool call is a contract between a schema and a language model, and an undocumented optional field is a contract clause written in invisible ink.&lt;/p&gt;

&lt;p&gt;This one is still in our open debt list. The fix we've scoped is a &lt;code&gt;before_tool_call&lt;/code&gt; hook that rejects &lt;code&gt;sessions_spawn&lt;/code&gt; without an &lt;code&gt;agentId&lt;/code&gt; and returns the specialist map in the error, so the model can retry correctly in the same turn — the same hook mechanism we already use for &lt;a href="https://dev.to/blog/agent-identity"&gt;agent identity&lt;/a&gt;. We haven't shipped it yet, and until we do, delegation via spawn is unreliable. Saying otherwise would be more comfortable and less true.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAILURE 3: THE PARENT THAT WAITED FOREVER
&lt;/h2&gt;

&lt;p&gt;When a parent delegates and yields, it's waiting for an announce-back from the child. That notification is best-effort. So:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Children can abort (timeout, reset, bug) and the notification to the parent is best-effort. If Core/Gateway restarts while a parent is yielded, the children's announce-back is lost and the parent hangs &lt;strong&gt;forever&lt;/strong&gt; — there's no sweeper to detect it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A conversation that is neither finished nor running, with no error anywhere, is the worst possible state: nothing to retry, nothing to report, nothing in the logs.&lt;/p&gt;

&lt;p&gt;The detector we built reads the &lt;strong&gt;last 8 KB&lt;/strong&gt; of each parent's session JSONL looking for a &lt;code&gt;sessions_yield&lt;/code&gt; not followed by an assistant message, and writes a row into &lt;code&gt;stuck_yield_sessions&lt;/code&gt; (migration &lt;code&gt;0187&lt;/code&gt;, idempotent via a unique key on &lt;code&gt;(user_id, session_key)&lt;/code&gt;). Two design details earned their keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A 3-minute grace window&lt;/strong&gt; (&lt;code&gt;STUCK_GRACE_MS&lt;/code&gt;) before flagging anything. The comment in the code explains why: &lt;em&gt;"so we don't false-positive during legitimately long delegations (Atlas with thinking + tool calls can take ~30 s before the first assistant text)."&lt;/em&gt; Without it, the detector's main output is noise about work that's going fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-resolution.&lt;/strong&gt; If the parent un-yields on its own, the row clears without anyone intervening. A recovery mechanism that requires a human to close its own tickets is a second job, not a fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recovery is a &lt;code&gt;POST /managed-agents/:id/stuck-yield/resume&lt;/code&gt; that injects a synthetic message into the parent, plus a button in the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAILURE 4: THE CONVERSATIONS NOBODY COULD SEE
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The runtime runs children internally and never emits WS events for their messages to Core. Result: in the dashboard's Conversations tab you see the chief ("I delegated to Atlas") but you don't see Atlas's conversation. To audit a council or a multi-agent workflow you have to open the raw JSONL in the workspace.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is an observability gap that becomes a correctness gap. If you can't see what the delegated agent did, you can't tell "Dali refused" from "Dali was never asked" from "Dali did it and the reply got lost" — which are three completely different bugs that present identically.&lt;/p&gt;

&lt;p&gt;The mirror tails each subagent's session JSONL with byte-offset watermarks (migration &lt;code&gt;0188&lt;/code&gt;, dedup index &lt;code&gt;0189&lt;/code&gt;, 1 MB read cap per pass) and writes the messages into &lt;code&gt;agent_conversations&lt;/code&gt; in two modes: &lt;code&gt;type='delegation'&lt;/code&gt; for child runs, and &lt;code&gt;type='webchat'&lt;/code&gt; for the parent's reply to the announce-back — which, as the code notes, &lt;em&gt;"happens inside the Gateway after &lt;code&gt;chat-bridge.callGateway&lt;/code&gt; has already returned"&lt;/em&gt;, and was therefore invisible to Core by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAILURE 5: THE POLICY WITH NOWHERE TO PLUG IN
&lt;/h2&gt;

&lt;p&gt;We built a &lt;code&gt;DelegationPolicy&lt;/code&gt;. Per-agent defaults, retry with backoff, an in-memory circuit breaker, storage for all of it. It was good work. We reverted it the same day, with migration &lt;code&gt;0180_drop_delegation_policy.sql&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The reason is in the doc, and it is the most useful paragraph in this post:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Delegation between agents happens via &lt;code&gt;sessions_spawn&lt;/code&gt;, which is &lt;strong&gt;a native LLM tool inside the runtime&lt;/strong&gt; — not an RPC that Core can intercept. With that API, the &lt;code&gt;DelegationPolicy&lt;/code&gt; we built has no insertion point:&lt;br&gt;
✅ Storage · ✅ Per-agent defaults · ✅ &lt;code&gt;withRetry&lt;/code&gt; helper with backoff · ✅ In-memory CircuitBreaker&lt;br&gt;
❌ &lt;strong&gt;Nobody invokes it&lt;/strong&gt; — Francis still delegates via &lt;code&gt;sessions_spawn&lt;/code&gt; directly.&lt;br&gt;
Keeping it alive was infrastructure without a consumer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Four components, all working, all correct, all unreachable. We had built a governance layer for a call path that never passes through us.&lt;/p&gt;

&lt;p&gt;We wrote down the three conditions that would unblock it — the runtime exposing &lt;code&gt;sessions.spawn&lt;/code&gt; as an RPC, changing the skill's contract to an HTTP &lt;code&gt;delegate_to&lt;/code&gt; against Core, or building our own spawning runtime ("several sprints, high risk") — and deleted the code. The conditions are the valuable artifact. The code was the expensive way to discover them.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAILURE 6: THE ALLOW-LIST THAT ALLOWED TOO MUCH
&lt;/h2&gt;

&lt;p&gt;Agent-to-agent messaging is gated by an allow-list. Ours was a flat list of wildcards, one per user: &lt;code&gt;paco--*&lt;/code&gt;, &lt;code&gt;pepito--*&lt;/code&gt;, &lt;code&gt;admin--*&lt;/code&gt;, ten entries. And the guard's formula was &lt;code&gt;matchesAllow(requester) &amp;amp;&amp;amp; matchesAllow(target)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Read that twice. Both sides match the list. &lt;code&gt;paco--francis&lt;/code&gt; matches. &lt;code&gt;pepito--atlas&lt;/code&gt; matches. &lt;strong&gt;So &lt;code&gt;paco--francis → pepito--atlas&lt;/code&gt; is allowed.&lt;/strong&gt; One user's coordinator could send work to another user's specialist, and everything downstream — per-user workspaces, per-user scopes, isolated memory — would be doing its job correctly on the wrong person's data.&lt;/p&gt;

&lt;p&gt;What was actually preventing it, as we noted at the time, is that the models don't know other users' agent IDs. That is not a security boundary. That is a fact about vocabulary.&lt;/p&gt;

&lt;p&gt;It's now closed deny-by-default in the identity plugin (&lt;code&gt;crossUserSendBlock&lt;/code&gt;): an agent can only send to agents sharing its own prefix. Legitimate cross-user delegation, when we build it, will be an explicit grant — not the absence of a check.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHAT I'D DO DIFFERENTLY
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Find the insertion point before writing the policy.&lt;/strong&gt; The &lt;code&gt;DelegationPolicy&lt;/code&gt; revert cost us a day and taught us something we could have learned in twenty minutes by asking "what function will call this?" before writing the first line. When you're building governance for a system you don't fully own, the first deliverable isn't the policy — it's the proof that the policy can be enforced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat tool schemas as prompts, because they are.&lt;/strong&gt; Two of the six failures above (the spawn/send confusion, the undescribed &lt;code&gt;agentId&lt;/code&gt;) are the model correctly following an under-specified contract. We spent weeks looking at model behavior when the bug was in a type declaration. The parameter descriptions in a tool schema are not documentation for humans reading the code; they are the only instructions the model receives at the moment it has to decide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the observability at the same time as the delegation, not after.&lt;/strong&gt; We shipped delegation, then spent a month unable to diagnose it because the child conversations were invisible. The mirror should have been part of the first version. A multi-agent system where you can only see one agent isn't a multi-agent system, it's a black box with a friendly name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assume best-effort notifications will be lost, and write the sweeper first.&lt;/strong&gt; The stuck-yield detector isn't clever; it's 8 KB of tail-read and a grace window. The mistake was shipping the yield before shipping the thing that notices when the yield never ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  ONE MORE THING: THE AGENTS SHARE A BRAIN
&lt;/h2&gt;

&lt;p&gt;A rule from the delegation guide that surprises everyone, including us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;While you wait, do NOTHING else: no sessions_history, no process, no exec, no
sessions_yield, no re-sending the task, no trying it yourself with Python/shell.
sessions_send already waits; the result comes back on its own. On this team the
agents SHARE an AI model: if you keep acting (e.g. polling their history), you hog
the model and the specialist CAN'T work → the task hangs. Stay still.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent that anxiously checks on its colleague's progress prevents its colleague from making progress. Seven agents, one model instance, and the failure mode is that helpfulness starves the thing it's trying to help.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE TAKEAWAY
&lt;/h2&gt;

&lt;p&gt;The topology was the easy decision. Seven specialists and a coordinator, flat, per-user, with their own scopes and their own workspaces — that shape has held up for four months without a single revision.&lt;/p&gt;

&lt;p&gt;Every hard problem lived in the handoff: a child with the wrong tools, a parameter the model couldn't see, a parent waiting on a message that got lost in a restart, a conversation nobody could read, a policy with nowhere to attach, and an allow-list held together by the fact that nobody knew the right names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're building multi-agent anything: the agents are not the system. The seams are the system.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: #16 — fitting seven agents into a model with an 80k context window, which is a story about a 45% cut, two conclusions I was wrong about, and one bug that turned out not to exist.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
      <category>devops</category>
    </item>
    <item>
      <title>Deterministic agent identity: a before_tool_call hook fills the token the model kept getting wrong</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Sun, 26 Jul 2026 10:42:27 +0000</pubDate>
      <link>https://dev.to/micelclaw/deterministic-agent-identity-a-beforetoolcall-hook-fills-the-token-the-model-kept-getting-wrong-3nln</link>
      <guid>https://dev.to/micelclaw/deterministic-agent-identity-a-beforetoolcall-hook-fills-the-token-the-model-kept-getting-wrong-3nln</guid>
      <description>&lt;p&gt;Francis is the main agent — the one you talk to in the dashboard. Dalí is the creative one, the sub-agent you hand an audio or an image task to. One day we asked Francis to have Dalí generate a short audio clip, and nothing came back. No error in the dashboard, no red toast, no timeout the user could see. Just silence.&lt;/p&gt;

&lt;p&gt;We logged the bug as "Dalí doesn't answer," and it sat in the tracker for a while looking like a flaky model or a bad prompt — the kind of thing you're tempted to blame on a small local model having a bad day.&lt;/p&gt;

&lt;p&gt;It was neither. It was an authentication hole with a very specific shape, and closing it meant taking a 64-character secret out of the language model's hands entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem the model couldn't see
&lt;/h2&gt;

&lt;p&gt;Our agents don't talk to Core with &lt;code&gt;curl&lt;/code&gt;. They call typed tools — &lt;code&gt;claw_notes&lt;/code&gt;, &lt;code&gt;claw_calendar&lt;/code&gt;, &lt;code&gt;claw_audio&lt;/code&gt; — served by an MCP facade that runs host-side at &lt;code&gt;127.0.0.1:7250&lt;/code&gt; (that's the previous post's subject). Every one of those tool calls has to carry an &lt;code&gt;auth_token&lt;/code&gt; argument. Core takes the token, resolves it to a &lt;code&gt;userId&lt;/code&gt; plus a set of scopes, and dispatches the call through its full pipeline via &lt;code&gt;app.inject()&lt;/code&gt; — auth, scope check, semantic-scope check, approval, handler, audit. The facade reimplements none of that security; the token is the single thing that lets the pipeline know who is asking.&lt;/p&gt;

&lt;p&gt;So the whole design rests on one question: where does that token come from?&lt;/p&gt;

&lt;p&gt;Before this fix, it came from the prompt. Two bridges injected it — &lt;code&gt;ws/chat-bridge.ts&lt;/code&gt; for webchat and &lt;code&gt;ws/voice-bridge.ts&lt;/code&gt; for voice. When you typed to Francis in the dashboard, the chat bridge dropped Francis's token into the prompt, and Francis was instructed to copy it into every &lt;code&gt;claw_*&lt;/code&gt; call. That works — mostly — for a main agent talking through a bridge.&lt;/p&gt;

&lt;p&gt;A subagent spawned with &lt;code&gt;sessions_spawn&lt;/code&gt; passes through &lt;strong&gt;neither&lt;/strong&gt; bridge. When Francis delegates to Dalí, there is no chat bridge and no voice bridge in the path. So Dalí received no token at all. Every &lt;code&gt;claw_*&lt;/code&gt; call it made failed auth, silently, and the delegated work just stopped. That was "Dalí doesn't answer": not a model refusing, a model that was never handed the credential it needed to do anything.&lt;/p&gt;

&lt;p&gt;And even the main agent, on the happy path, was fragile. We were asking a language model to reproduce a 64-hex-character secret, verbatim, on every single call. A capable model does it — most of the time. But a secret the model has to retype is a secret the model will eventually get wrong, and when it improvised a tool it hadn't been shown properly, it improvised the token too.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fact&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real runtime token&lt;/td&gt;
&lt;td&gt;64 hex characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copied correctly on a capable model&lt;/td&gt;
&lt;td&gt;6 / 6 (Qwen3.6-35B IQ2_M, ~27K-token context)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value emitted when it improvised a wrong tool&lt;/td&gt;
&lt;td&gt;42 characters — not in the database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subagent success rate before the fix&lt;/td&gt;
&lt;td&gt;0% (no token was ever injected)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 6/6 number is the trap. On rails, a strong model looks perfectly reliable, and you conclude the design is fine. Then the same model, one wrong turn later, emits a 42-character string that matches nothing in the database — and the subagents, which you can't watch as closely, get it wrong 100% of the time because they get nothing at all. Fragile by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just a header
&lt;/h2&gt;

&lt;p&gt;The obvious fix is a header. MCP servers usually authenticate with a static header — so put the token there, stop asking the model to carry it, done.&lt;/p&gt;

&lt;p&gt;It doesn't work here. In OpenClaw, the &lt;code&gt;mcp.servers&lt;/code&gt; config is &lt;strong&gt;global&lt;/strong&gt;. The headers are static and shared across every agent — there is no per-agent identity per session. If we hard-coded a token into the server's headers, every agent on the box would authenticate as the same identity. That destroys the entire point of per-agent scopes: Dalí is supposed to have &lt;code&gt;audio:*&lt;/code&gt;/&lt;code&gt;media:*&lt;/code&gt; and nothing else; Francis has its full grant. A shared header can't express "this call is Dalí, that call is Francis."&lt;/p&gt;

&lt;p&gt;That global-static constraint is exactly why &lt;a href="https://micelclaw.com/blog/mcp-facade" rel="noopener noreferrer"&gt;ADR-10&lt;/a&gt; put identity in a tool argument in the first place. ADR-11 doesn't undo that decision — the facade still dispatches &lt;code&gt;Authorization: Bearer &amp;lt;auth_token&amp;gt;&lt;/code&gt; through &lt;code&gt;app.inject()&lt;/code&gt;. All that changes is &lt;em&gt;who fills the argument&lt;/em&gt;. Instead of the model, it's the host.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hook
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fie225flt8zphbxwynzqm.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fie225flt8zphbxwynzqm.gif" alt="The model types the auth_token by hand and eventually gets it wrong — a 42-character string that matches nothing, denied. Then the before_tool_call hook stamps the right 64-hex token host-side, keyed by ctx.agentId, and the same call is allowed with per-agent scopes." width="720" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenClaw lets a native plugin register a &lt;code&gt;before_tool_call&lt;/code&gt; hook — a function that runs on the host, sees every tool call before it executes, and can rewrite its params. We wrote one: &lt;code&gt;openclaw-plugins/claw-identity/index.js&lt;/code&gt;, plain ESM JavaScript, no build step. The core of it is about five lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The core of openclaw-plugins/claw-identity/index.js — the before_tool_call hook&lt;/span&gt;
&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;before_tool_call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;toolName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claw-os__&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// only the MCP facade tools&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadTokenMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mapPath&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;  &lt;span class="c1"&gt;// map keyed by ctx.agentId&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                  &lt;span class="c1"&gt;// no entry -&amp;gt; don't touch the call&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;auth_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// override host-side&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole idea. For any tool whose name starts with the facade prefix &lt;code&gt;claw-os__&lt;/code&gt;, the hook looks up the calling agent by &lt;code&gt;ctx.agentId&lt;/code&gt; in a token map and rewrites &lt;code&gt;params.auth_token&lt;/code&gt; to that agent's token. Whatever value the model put there is overwritten. If there's no map entry for the agent, the hook returns &lt;code&gt;undefined&lt;/code&gt; and leaves the call untouched — it never breaks a call it doesn't recognize.&lt;/p&gt;

&lt;p&gt;The model never sees the token, never types it, never hallucinates it. And because the key is &lt;code&gt;ctx.agentId&lt;/code&gt;, a subagent spawned via &lt;code&gt;sessions_spawn&lt;/code&gt; gets &lt;em&gt;its own&lt;/em&gt; token injected host-side, with &lt;em&gt;its own&lt;/em&gt; scopes — the exact case that used to fail silently. Determinism, keyed on who is actually running the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The per-agent token and the map
&lt;/h2&gt;

&lt;p&gt;Where does the map come from? Core mints it. &lt;code&gt;syncUserRuntimeTokens(userId)&lt;/code&gt; in &lt;code&gt;core/src/services/agent-runtime-token.service.ts&lt;/code&gt; creates exactly one canonical &lt;code&gt;agent_tokens&lt;/code&gt; row per agent, with &lt;code&gt;name='runtime'&lt;/code&gt;. The database stores only the sha256 hash; the plaintext exists only in an on-disk map.&lt;/p&gt;

&lt;p&gt;That map is &lt;code&gt;~/.config/micelclaw/agent-tokens.json&lt;/code&gt;, and its shape is deliberately dull:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"paco--francis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…64 hex chars…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"paco--dali"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;"…64 hex chars…"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keys are exactly the &lt;code&gt;ctx.agentId&lt;/code&gt; values the hook looks up — &lt;code&gt;&amp;lt;prefix&amp;gt;--&amp;lt;agent&amp;gt;&lt;/code&gt;, one per user-agent. Writing that file is where we were careful, because the plugin reads it live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Atomic map write — core/src/services/agent-runtime-token.service.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeMapAtomic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runtimeTokenMapPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// ~/.config/micelclaw/agent-tokens.json&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;configDir&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mo"&gt;0o700&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mo"&gt;0o600&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                           &lt;span class="c1"&gt;// atomic swap: the plugin never reads a half file&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mkdir&lt;/code&gt; with mode &lt;code&gt;0o700&lt;/code&gt;, the file itself &lt;code&gt;0o600&lt;/code&gt;, and a temp-file-plus-rename so the swap is atomic. The plugin reads through an mtime cache (&lt;code&gt;loadTokenMap&lt;/code&gt;): it only re-reads from disk when Core actually rewrites the file, and on any read error it falls back to the last-known map and never throws. That mtime-triggered re-read is exactly why the write has to be atomic — if we wrote the file in place, the plugin could catch it half-written and hand out a truncated token.&lt;/p&gt;

&lt;p&gt;The scopes matter as much as the token. The desired scope set is derived from the agent's live role grant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deriveScopesFromSkills(skillScopeDomains(skills), roleScopesFor(agentDbId), perms)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;roleScopesFor&lt;/code&gt; returns the agent's broadest base token — so Francis keeps its full grant (~17 scopes) and Dalí keeps &lt;code&gt;audio:*&lt;/code&gt;/&lt;code&gt;media:*&lt;/code&gt;. Nothing is downgraded, nothing is escalated: the runtime token carries exactly the identity the agent already had. And it is re-minted &lt;strong&gt;only&lt;/strong&gt; when the scopes actually change — a &lt;code&gt;scopesEqual&lt;/code&gt; guard keeps it stable otherwise, so we're not churning credentials on every sync.&lt;/p&gt;

&lt;p&gt;Here's the whole thing in four pieces:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The hook&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openclaw-plugins/claw-identity/index.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;before_tool_call&lt;/code&gt; → injects &lt;code&gt;auth_token&lt;/code&gt;, priority 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token service&lt;/td&gt;
&lt;td&gt;&lt;code&gt;core/src/services/agent-runtime-token.service.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mints one &lt;code&gt;runtime&lt;/code&gt; token per agent, writes the map&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;core/src/services/openclaw-bootstrap.service.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Idempotent &lt;code&gt;configPatch&lt;/code&gt; at boot + on regenerate-tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The facade&lt;/td&gt;
&lt;td&gt;&lt;code&gt;core/src/mcp/claw-os-mcp-server.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Serves &lt;code&gt;claw_*&lt;/code&gt; at &lt;code&gt;127.0.0.1:7250&lt;/code&gt;, dispatches via &lt;code&gt;app.inject()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Rollout and verification
&lt;/h2&gt;

&lt;p&gt;The hook only helps if the plugin actually loads. Registration is an idempotent &lt;code&gt;configPatch&lt;/code&gt; — &lt;code&gt;plugins.load.paths&lt;/code&gt; plus &lt;code&gt;plugins.entries.claw-identity.enabled&lt;/code&gt; — done by &lt;code&gt;ensureClawIdentityPluginRegistered()&lt;/code&gt; in &lt;code&gt;openclaw-bootstrap.service.ts&lt;/code&gt;, both at boot and from &lt;code&gt;regenerate-tools&lt;/code&gt;. The plugin then loads on the next Gateway reload.&lt;/p&gt;

&lt;p&gt;We didn't trust that it worked until the logs said so. Two lines in the Gateway log (&lt;code&gt;/tmp/openclaw/openclaw-YYYY-MM-DD.log&lt;/code&gt;) tell the whole story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[claw-identity] plugin cargado; mapa de tokens = /home/victor/.config/micelclaw/agent-tokens.json
[claw-identity] before_tool_call tool=claw-os__claw_notes agent=paco--francis token=ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line says the plugin loaded and resolved its map. The second says the hook fired for a real MCP tool call, for the right agent, and injected a token that Core accepted (&lt;code&gt;token=ok&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Then the end-to-end proof — the one that actually closed "Dalí doesn't answer." We sent Dalí a direct message: &lt;em&gt;"genera un audio que diga Hola caracola."&lt;/em&gt; The log showed &lt;code&gt;before_tool_call tool=claw-os__claw_audio agent=paco--dali token=ok&lt;/code&gt;. Dalí called the real &lt;code&gt;claw_audio&lt;/code&gt; tool — not a shell workaround, the actual tool — and an &lt;code&gt;.ogg&lt;/code&gt; file landed in Drive with a real &lt;code&gt;file_id&lt;/code&gt;. The subagent that had never received a token now authenticates like any other agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas
&lt;/h2&gt;

&lt;p&gt;None of this was clean the first time, and the credibility of a fix like this is in the sharp edges we hit getting there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;plugins.entries.&amp;lt;id&amp;gt;&lt;/code&gt; rejects &lt;code&gt;env&lt;/code&gt;.&lt;/strong&gt; The entry schema only accepts &lt;code&gt;enabled&lt;/code&gt;, &lt;code&gt;hooks&lt;/code&gt;, and &lt;code&gt;config&lt;/code&gt;. A &lt;code&gt;configPatch&lt;/code&gt; with an &lt;code&gt;env&lt;/code&gt; key returns &lt;code&gt;INVALID_REQUEST: Unrecognized key: "env"&lt;/code&gt; — and the &lt;em&gt;whole&lt;/em&gt; patch is dropped, not just the bad key. So the plugin resolves the map path by its default (&lt;code&gt;~/.config/micelclaw/agent-tokens.json&lt;/code&gt;) rather than reading it from the entry's env.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The MCP SDK strips undeclared params.&lt;/strong&gt; Any tool-call argument not declared in the tool's &lt;code&gt;inputSchema&lt;/code&gt; disappears silently. We host-inject an internal &lt;code&gt;_conversation_id&lt;/code&gt; (so Core can route L2 approval cards back to the chat they came from) — and it &lt;em&gt;must&lt;/em&gt; be declared in the schema, or the SDK drops it on the floor and the routing quietly breaks. Same class of trap as the token itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The plugin needs a local SDK symlink in dev.&lt;/strong&gt; &lt;code&gt;index.js&lt;/code&gt; imports &lt;code&gt;openclaw/plugin-sdk/plugin-entry&lt;/code&gt;; from its own directory, Node needs a local &lt;code&gt;node_modules/openclaw&lt;/code&gt; symlink pointing at &lt;code&gt;/usr/lib/node_modules/openclaw&lt;/code&gt; (gitignored). A packaged deploy installs the plugin with its own dependencies; dev has to fake it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot registration can lose the WS race.&lt;/strong&gt; If the Gateway WebSocket isn't up yet when Core tries to register the plugin, the &lt;code&gt;configPatch&lt;/code&gt; is dropped and retried. That's why &lt;code&gt;POST /managed-agents/regenerate-tools&lt;/code&gt; re-ensures registration once the WS is reliably up — it's the belt to the boot-time suspenders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tsx watch&lt;/code&gt; doesn't hot-reload reliably on WSL.&lt;/strong&gt; Code changes need a full Core restart to take effect, and the plugin itself only loads on a Gateway reload. Edit-and-refresh does not apply here; you verify against the real, restarted runtime or you verify nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it grew
&lt;/h2&gt;

&lt;p&gt;Worth being honest about how this file aged. It started as ~40 lines that did one thing: fill &lt;code&gt;auth_token&lt;/code&gt;. The current &lt;code&gt;index.js&lt;/code&gt; is 327 lines, because the hook turned out to be the natural place to enforce anything that has to be true &lt;em&gt;host-side, keyed by agent identity&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;It now also redirects &lt;code&gt;sessions_spawn&lt;/code&gt;→&lt;code&gt;sessions_send&lt;/code&gt; for cross-agent delegation; enforces a deny-by-default cross-&lt;strong&gt;user&lt;/strong&gt; &lt;code&gt;sessions_send&lt;/code&gt; guard (&lt;code&gt;crossUserSendBlock&lt;/code&gt;); injects a &lt;code&gt;wb_&amp;lt;prefix&amp;gt;&lt;/code&gt; &lt;code&gt;boardId&lt;/code&gt; into the five board-scoped workboard tools; ensures a dedicated delegation session per (conversation × specialist); and injects the &lt;code&gt;_conversation_id&lt;/code&gt; mentioned above. Each of those is a place where the model shouldn't be trusted to get identity right, and the hook is already sitting on the one chokepoint every tool call passes through — so it kept accreting responsibilities.&lt;/p&gt;

&lt;p&gt;That's powerful and it's a smell at the same time. What began as "the auth_token hook" is really "the per-agent host-side identity layer" now, and the name on the tin no longer describes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ship identity out of the prompt from day one.&lt;/strong&gt; Having the model copy its own token "works" on a strong model and fails silently for subagents and weak ones. The 6/6 copy rate lulled us into thinking the design was sound; the host-side hook should have been the first design, not the fix that arrived after "Dalí doesn't answer." If a component has to reproduce a secret to function, that's the bug, no matter how reliable the reproduction looks in a demo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide up front whether the hook is single-purpose or the identity chokepoint.&lt;/strong&gt; It has since absorbed delegation redirection, a cross-user deny guard, workboard board-scoping, and conversation-id routing. That's a coherent set of responsibilities — but nobody &lt;em&gt;decided&lt;/em&gt; it should be one file, it just grew from ~40 to ~330 lines one commit at a time. I'd name and document it as "the per-agent host-side identity layer" from the start, so its scope is a choice rather than an accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finish the cleanup.&lt;/strong&gt; Two follow-ups are still open in the source doc. The chat and voice bridges still inject the identity token into the prompt as a fallback — redundant now that the hook overwrites it — and &lt;code&gt;auth_token&lt;/code&gt; is still a declared field on the tool input schema. Once the hook is proven in production, both should go: drop &lt;code&gt;auth_token&lt;/code&gt; from the schema, and slim the now-pointless token out of the bridge prompts. Leaving a fallback in place feels safe, but a fallback the model can still fill is a value the model can still get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If your model has to copy a secret, it will eventually get it wrong — and the calls you never see, the ones subagents make, will get it wrong 100% of the time. Move identity out of the prompt and into a host-side &lt;code&gt;before_tool_call&lt;/code&gt; hook: deterministic, per-agent, each agent keeps its own scopes, and the model can't hallucinate what it never handles.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: #15 — Multi-agent topology: 7 agents per user, delegation, and the failure modes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>architecture</category>
      <category>llm</category>
    </item>
    <item>
      <title>The MCP facade: how agents talk to the backend without curl</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Thu, 23 Jul 2026 06:09:03 +0000</pubDate>
      <link>https://dev.to/micelclaw/the-mcp-facade-how-agents-talk-to-the-backend-without-curl-3pmo</link>
      <guid>https://dev.to/micelclaw/the-mcp-facade-how-agents-talk-to-the-backend-without-curl-3pmo</guid>
      <description>&lt;p&gt;An agent skill runs &lt;code&gt;curl http://127.0.0.1:7200/api/v1/notes&lt;/code&gt; from inside its Docker sandbox. It fails with exit code 7 — "couldn't connect" — before authentication even runs, because the sandbox is launched with &lt;code&gt;network: none&lt;/code&gt;. There is no loopback to reach. There is no network at all.&lt;/p&gt;

&lt;p&gt;Meanwhile, the &lt;code&gt;SKILL.md&lt;/code&gt; files were confidently telling the model to authenticate with a &lt;code&gt;$CLAW_API_KEY&lt;/code&gt; environment variable that didn't exist inside the sandbox in the first place.&lt;/p&gt;

&lt;p&gt;So the picture was: our agents had been instructed to talk to a backend they could never reach, using a token that wasn't there. Every write to a note, every calendar lookup, every "remember this" — all of it was a &lt;code&gt;curl&lt;/code&gt; into a wall. This post is about the MCP facade that replaced that model, and the one finding that made the whole design fall out cleanly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# inside the agent's Docker sandbox (network: none)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl http://127.0.0.1:7200/api/v1/notes
&lt;span class="c"&gt;# exit code 7 — couldn't connect, before auth even runs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix we almost built
&lt;/h2&gt;

&lt;p&gt;The first instinct was to punch a hole through the wall without taking it down. Keep &lt;code&gt;network: none&lt;/code&gt; entirely — the isolation is a feature, not an accident — and hand the sandbox a filesystem socket instead of a network address. Bind-mount a Unix socket into the container and the agent could do &lt;code&gt;curl --unix-socket /run/claw.sock http://core/api/v1/notes&lt;/code&gt;: no TCP, no network namespace, just a file descriptor mounted into the container. Call it option C.&lt;/p&gt;

&lt;p&gt;Option C was sound. A bind-mounted socket genuinely sidesteps the &lt;code&gt;network: none&lt;/code&gt; wall that killed exit-7 &lt;code&gt;curl&lt;/code&gt;, because a Unix socket isn't a network — it's a file. It would have worked.&lt;/p&gt;

&lt;p&gt;And then we found something that made it irrelevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding that changed everything
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol has a client and a server. We had been assuming the MCP &lt;em&gt;client&lt;/em&gt; — the thing that actually issues the tool call — would live wherever the agent's code lives: inside the sandbox.&lt;/p&gt;

&lt;p&gt;It doesn't. The MCP client runs &lt;strong&gt;host-side&lt;/strong&gt;, in the OpenClaw runtime process, not inside the agent's Docker container. When a model emits a tool call, the runtime — outside the sandbox — is what dispatches it.&lt;/p&gt;

&lt;p&gt;That single fact rewrites the problem. A tool call never touches the sandbox's network namespace, because it never originates inside the sandbox. The &lt;code&gt;network: none&lt;/code&gt; wall — the exact wall that killed &lt;code&gt;curl&lt;/code&gt; with exit 7 — is simply not in the path. There is nothing to tunnel through, no socket to bind-mount, no bridge to build.&lt;/p&gt;

&lt;p&gt;Option C became obsolete overnight. It would have solved a real problem — reach Core without a network — but host-side MCP solved the same problem more cleanly, with no second listener to run and secure. We shelved it before writing a line of it.&lt;/p&gt;

&lt;p&gt;The shape was now obvious. Core would run its own MCP server — the facade — and expose each domain of the API as a typed tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Facade:  core/src/mcp/claw-os-mcp-server.ts
         streamable-HTTP MCP server on 127.0.0.1:7250
         (Core itself is on 127.0.0.1:7200)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each domain gets one tool named &lt;code&gt;claw_&amp;lt;domain&amp;gt;&lt;/code&gt; with an &lt;code&gt;action&lt;/code&gt; enum. &lt;code&gt;claw_notes&lt;/code&gt; takes &lt;code&gt;action: create | list | get | update | delete&lt;/code&gt;. &lt;code&gt;claw_calendar&lt;/code&gt;, &lt;code&gt;claw_contacts&lt;/code&gt;, &lt;code&gt;claw_finance&lt;/code&gt;, and the rest follow the same mould. The model never learns a URL. It learns a vocabulary of typed tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  A translator, not a second backend
&lt;/h2&gt;

&lt;p&gt;Here was the decision that mattered most, and the one we kept coming back to: &lt;strong&gt;the facade is a thin translator, not a reimplementation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The tempting version of this project is a second backend — a service that knows how to create a note, check a scope, run an approval, sign an audit record. That version is a disaster. It's a duplicate of the real Core that drifts out of sync the first time someone changes a validation rule and forgets the copy.&lt;/p&gt;

&lt;p&gt;We refused to write it. Every facade tool call does exactly three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Look up the capability registry → &lt;code&gt;(method, url, body, query)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Build an &lt;em&gt;internal&lt;/em&gt; HTTP request carrying &lt;code&gt;Authorization: Bearer &amp;lt;auth_token&amp;gt;&lt;/code&gt; plus &lt;code&gt;x-claw-capability: &amp;lt;domain&amp;gt;.&amp;lt;action&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Dispatch that request through the &lt;strong&gt;full Fastify pipeline&lt;/strong&gt; via &lt;code&gt;app.inject()&lt;/code&gt;: &lt;code&gt;auth → scope-validation → semantic-scope → approval-check → handler → audit&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;app.inject()&lt;/code&gt; is Fastify's in-process request injector — it runs a request through every plugin and middleware the real HTTP server runs, without a socket. So a &lt;code&gt;claw_notes&lt;/code&gt; call and a human's &lt;code&gt;POST /api/v1/notes&lt;/code&gt; walk the &lt;em&gt;same hallway&lt;/em&gt;. The facade adds the &lt;code&gt;Bearer&lt;/code&gt; prefix, names the capability, and gets out of the way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// what the agent actually calls (facade tool, host-side on :7250)&lt;/span&gt;
&lt;span class="nx"&gt;claw_notes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;params&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tags&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;per-conversation session token&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;// bare value, NO "Bearer"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// what the facade builds internally and dispatches via app.inject()&lt;/span&gt;
&lt;span class="nx"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Bearer&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;auth_token&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;   &lt;span class="c1"&gt;// the facade adds the "Bearer"&lt;/span&gt;
&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;claw&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;create&lt;/span&gt;
&lt;span class="c1"&gt;// → auth → scope-validation → semantic-scope → approval-check → handler → audit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because every call routes through the real pipeline, everything the pipeline already enforces stays enforced, with nothing rewritten:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preserved for free&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deny-by-default scopes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;scope-validation.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L0–L3 approval levels&lt;/td&gt;
&lt;td&gt;&lt;code&gt;approval-check.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ed25519 audit signing&lt;/td&gt;
&lt;td&gt;audit stage of the pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier gating (Free/Pro/Plus)&lt;/td&gt;
&lt;td&gt;the handlers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRUD hooks (embed / heat / extract)&lt;/td&gt;
&lt;td&gt;post-write pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credits accounting&lt;/td&gt;
&lt;td&gt;the handlers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The agent's identity travels in the &lt;code&gt;auth_token&lt;/code&gt; argument; &lt;code&gt;auth.ts&lt;/code&gt; resolves the fixed &lt;code&gt;userId&lt;/code&gt; from it, which is what makes impersonation impossible — a model cannot claim to be someone else by editing a field, because the token &lt;em&gt;is&lt;/em&gt; the identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The capability registry
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;(method, url, body, query)&lt;/code&gt; lookup in step 1 comes from a declarative registry: one file per domain under &lt;code&gt;core/src/mcp/registry/domains/*.ts&lt;/code&gt;. Each &lt;code&gt;Capability&lt;/code&gt; maps a &lt;code&gt;(domain, action, params)&lt;/code&gt; triple to exactly one internal HTTP request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// registry entry: (domain, action, params) -&amp;gt; one internal request&lt;/span&gt;
&lt;span class="c1"&gt;// core/src/mcp/registry/domains/notes.ts&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;create&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;write:notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;L0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This registry is the single source of truth, and it earns its keep by feeding two consumers at once. The same declarations generate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The facade's tool catalog (what the model sees as callable tools).&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;GET /managed-agents/available-tools&lt;/code&gt; endpoint that powers dynamic Tool Access (what an admin toggles per agent in the dashboard).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One file, two surfaces, no chance of them disagreeing. Add a capability and it shows up in both the model's vocabulary and the permission UI in the same commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security had to come first
&lt;/h2&gt;

&lt;p&gt;There's an uncomfortable truth buried in this whole design. For as long as the agents lived behind &lt;code&gt;network: none&lt;/code&gt;, a lot of latent vulnerabilities didn't matter — they were contained by a wall that happened to block &lt;em&gt;everything&lt;/em&gt;. The moment we connected agents host-side, that wall came down, and those vulnerabilities became live.&lt;/p&gt;

&lt;p&gt;So the actual first phase of this project (Fase 0) wasn't the facade at all. It was closing three holes &lt;em&gt;before&lt;/em&gt; exposing anything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guard&lt;/th&gt;
&lt;th&gt;What it closed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST/PATCH /agent-tokens&lt;/code&gt; now returns &lt;code&gt;403&lt;/code&gt; when &lt;code&gt;keyScope === 'agent'&lt;/code&gt; — an agent can't mint or widen its own token (no self-escalation).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The same &lt;code&gt;403&lt;/code&gt; on &lt;code&gt;requireAdmin&lt;/code&gt; routes — an agent token can never reach an admin surface.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;scope-validation&lt;/code&gt; flipped to &lt;strong&gt;deny-by-default&lt;/strong&gt; — an unmapped resource is denied, not allowed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;C3 is the one that changes the posture of the whole system. Before, "we didn't map this resource" meant "anything goes." After, it means "no." Sensitive domains had to be re-exposed deliberately and carefully on top of that floor — the kind of thing that, done in the wrong order, quietly ships a hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity: the model stopped copying the token
&lt;/h2&gt;

&lt;p&gt;The first version of the auth model (ADR-10) made the model responsible for copying its own &lt;code&gt;auth_token&lt;/code&gt; — a 64-hex secret — into every single &lt;code&gt;claw_*&lt;/code&gt; call. On a capable model, on rails, this works. But it's fragile by construction: the moment the model improvises a wrong tool call, it also mangles or hallucinates the token, and worse, &lt;strong&gt;subagents got no token at all&lt;/strong&gt;. A child spawned via &lt;code&gt;sessions_spawn&lt;/code&gt; doesn't pass through the chat bridge that injected the token, so delegated work silently failed. This is the real reason behind the months-old complaint that "Dali doesn't answer" when you delegate to her.&lt;/p&gt;

&lt;p&gt;ADR-11 fixed it by taking the model out of the loop entirely. A native OpenClaw plugin, &lt;code&gt;openclaw-plugins/claw-identity&lt;/code&gt;, registers a &lt;code&gt;before_tool_call&lt;/code&gt; hook. For any tool named &lt;code&gt;claw-os__*&lt;/code&gt;, the hook overwrites &lt;code&gt;params.auth_token&lt;/code&gt; with the correct &lt;strong&gt;per-agent&lt;/strong&gt; token, resolved host-side from &lt;code&gt;ctx.agentId&lt;/code&gt; against a token map at &lt;code&gt;~/.config/micelclaw/agent-tokens.json&lt;/code&gt; (chmod 600). The model's value, if it bothered to supply one, is ignored.&lt;/p&gt;

&lt;p&gt;Deterministic, and — crucially — it covers subagents. Any agent, main or spawned, gets &lt;em&gt;its own&lt;/em&gt; token injected host-side. It can't hallucinate a token it never has to write. (That hook is a whole story of its own; it's next week's post.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills stopped being API docs
&lt;/h2&gt;

&lt;p&gt;Once the mechanics — URL, headers, method, request shape — moved into the typed tool and its schema, the &lt;code&gt;SKILL.md&lt;/code&gt; files had nothing left to document. So they became something better.&lt;/p&gt;

&lt;p&gt;Each skill shrank from a full API reference to a ~25–45 line &lt;strong&gt;judgment guide&lt;/strong&gt;: not &lt;em&gt;how&lt;/em&gt; to call the endpoint (the schema captures that) but &lt;em&gt;when and how&lt;/em&gt; — the taste and context a schema can't encode. "Prefer &lt;code&gt;list&lt;/code&gt; with a tag filter before &lt;code&gt;get&lt;/code&gt;." "Don't create a duplicate contact; search first." The skill teaches the decision, the tool carries the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auditing the real surface
&lt;/h2&gt;

&lt;p&gt;One rule we set early: never mirror the capability registry to describe the API. Audit the real REST surface, because the registry is a deliberate &lt;em&gt;subset&lt;/em&gt; of it, and pretending otherwise hides what's exposed.&lt;/p&gt;

&lt;p&gt;So on 2026-07-03 we cross-audited both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;REST endpoints in &lt;code&gt;routes/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capabilities across code domains&lt;/td&gt;
&lt;td&gt;424&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code domains&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REST surface deliberately &lt;strong&gt;not&lt;/strong&gt; exposed to agents&lt;/td&gt;
&lt;td&gt;~60%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That ~60% is intentional, not a backlog. It's admin, config, binary, and UI-only endpoints that agents have no business touching. The audit's value is precisely in making the gap explicit — you can only reason about what an agent can do if you've measured what the surface actually is, endpoint by endpoint, rather than trusting the shorter list to speak for the longer one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas that cost us
&lt;/h2&gt;

&lt;p&gt;Wiring typed tools into a model turned out to have sharp edges that had nothing to do with the design and everything to do with the plumbing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The MCP SDK silently strips undeclared args.&lt;/strong&gt; Any argument not declared in a tool's &lt;code&gt;inputSchema&lt;/code&gt; is dropped before it reaches the facade. Since the identity hook &lt;em&gt;injects&lt;/em&gt; &lt;code&gt;auth_token&lt;/code&gt; (and &lt;code&gt;_conversation_id&lt;/code&gt;) host-side, those fields must still be declared in the schema — otherwise the SDK strips the hook's injected value and it dies silently on the way in. The thing you inject must be a thing you declared.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;z.record&lt;/code&gt; schema sent a weak model into an 84-call loop.&lt;/strong&gt; Our first cut emitted the tool's &lt;code&gt;params&lt;/code&gt; as &lt;code&gt;z.record&lt;/code&gt; → JSON Schema &lt;code&gt;properties: {}&lt;/code&gt; — no declared fields. A small local model (Francis, a 35B-A3B at IQ2_M) was asked for "contacts starting with a" and made &lt;strong&gt;84 calls with &lt;code&gt;params: {}&lt;/code&gt;&lt;/strong&gt;, completely unable to construct &lt;code&gt;params: { search: "a" }&lt;/code&gt; because the schema gave it nothing to fill in. We fixed it in &lt;code&gt;tool-builder.ts&lt;/code&gt; by generating &lt;code&gt;params&lt;/code&gt; as the union of every action's &lt;code&gt;.shape&lt;/code&gt;, &lt;code&gt;.partial().passthrough()&lt;/code&gt; — real, named fields the model can reason about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One &lt;code&gt;.email()&lt;/code&gt; took down every Groq chat.&lt;/strong&gt; zod v4's &lt;code&gt;.email()&lt;/code&gt; emits a regex with lookaheads. Groq compiles every tool schema with an RE2 engine, and RE2 has no lookarounds — so it returned a &lt;code&gt;400&lt;/code&gt; and rejected the &lt;em&gt;entire&lt;/em&gt; chat, for every agent that happened to carry the &lt;code&gt;files&lt;/code&gt; domain. One field, one lookahead, whole conversation dead. Fixed with a generic lookaround-stripping sanitizer in &lt;code&gt;tool-builder.ts&lt;/code&gt; (the real validation still runs against the original zod, so nothing loosens).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And a preset gotcha:&lt;/strong&gt; &lt;code&gt;bundle-mcp&lt;/code&gt; — the tool key that hands the agent the facade at all — must be in &lt;code&gt;tools.sandbox.tools.alsoAllow&lt;/code&gt;, or the sandbox prunes it and the agent sees none of the facade tools. It's in the coding, messaging, and full presets for exactly this reason.&lt;/p&gt;

&lt;p&gt;For completeness, a handful of skills were &lt;em&gt;not&lt;/em&gt; migrated and still &lt;code&gt;curl&lt;/code&gt;, on purpose: &lt;code&gt;claw-pdf-tools&lt;/code&gt; (binary endpoints), &lt;code&gt;claw-bind&lt;/code&gt; (lives under &lt;code&gt;/api/v1/auth&lt;/code&gt;, uses a JWT/system token), and the four &lt;code&gt;claw-app-*&lt;/code&gt; meta-skills (app authoring, an admin flow).&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I'd ship the Fase 0 hardening as the foundation, before wiring any agent host-side.&lt;/strong&gt; Connecting agents lit up vulnerabilities that &lt;code&gt;network: none&lt;/code&gt; had been quietly containing. Deny-by-default and the C1/C2/C3 guards should have been the first commit, not a prerequisite we scrambled to close. Ordering security after exposure is how you ship a window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd inject identity host-side from day one (ADR-11), not make the model copy a 64-hex token (ADR-10).&lt;/strong&gt; The model-copies-the-token design was fragile against weak models and broke subagents &lt;em&gt;entirely&lt;/em&gt; — &lt;code&gt;sessions_spawn&lt;/code&gt; children got no token, which is the whole reason delegated work silently failed. A &lt;code&gt;before_tool_call&lt;/code&gt; hook is both more robust and simpler. We took the long way there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd declare the params schema shape up front.&lt;/strong&gt; The empty-&lt;code&gt;properties&lt;/code&gt; &lt;code&gt;z.record&lt;/code&gt; schema sent a small model into a loop it could not escape — 84 calls, all &lt;code&gt;params: {}&lt;/code&gt;. It read like a model failure and was a schema-shape bug. Give the model named fields and it fills them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd sanitize tool schemas for RE2 from the start.&lt;/strong&gt; A single &lt;code&gt;.email()&lt;/code&gt; in one domain &lt;code&gt;400&lt;/code&gt;'d every Groq chat for every agent carrying that domain. A one-line validator emitting a lookahead took down whole conversations. Cheap to prevent, expensive to diagnose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The agent never learned an API. It learned a vocabulary of typed tools — and every call still walks the same &lt;code&gt;auth → scope → approval → audit&lt;/code&gt; hallway a human's request does. That's the entire trick: the facade is a translator, not a second backend. It reuses the whole pipeline via &lt;code&gt;app.inject()&lt;/code&gt; and rewrites nothing. The AI can only touch what you allow, and everything it touches passes the exact permission checks and audit log as if you'd done it yourself — because, mechanically, it did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: #14 — Deterministic agent identity: injecting the per-agent token with a before_tool_call hook&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Building a process manager for 40 services on 8GB of RAM</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Wed, 22 Jul 2026 07:56:55 +0000</pubDate>
      <link>https://dev.to/micelclaw/building-a-process-manager-for-40-services-on-8gb-of-ram-19aa</link>
      <guid>https://dev.to/micelclaw/building-a-process-manager-for-40-services-on-8gb-of-ram-19aa</guid>
      <description>&lt;p&gt;At some point during development, I ran &lt;code&gt;docker ps&lt;/code&gt; and counted 16 containers. Then there were the systemd services — PostgreSQL, SSH, the core server itself. Plus internal workers: the job scheduler, the async queue, the WebSocket server, the OpenClaw gateway. And Ollama, sitting quietly with a 4B model eating 5.4GB of VRAM.&lt;/p&gt;

&lt;p&gt;40 processes. On a machine with 8GB of RAM.&lt;/p&gt;

&lt;p&gt;Not all of them need to run simultaneously. The mail server needs to be always on. The PDF converter only needs to run when someone opens a document. Jellyfin only needs to run in the evenings. The voice STT model can sleep unless someone is talking.&lt;/p&gt;

&lt;p&gt;This post is about the process manager that orchestrates all of that — a unified view of everything running on the system, a RAM budget engine that decides what fits, and lifecycle policies that start and stop services based on actual usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unified view
&lt;/h2&gt;

&lt;p&gt;The first problem was visibility. Docker containers report stats through the Docker API. Systemd services report through &lt;code&gt;systemctl&lt;/code&gt;. Internal Node.js workers (scheduler, queue, WebSocket) only exist in the Claw Core process. Ollama has its own API for model management. Four different systems, four different interfaces.&lt;/p&gt;

&lt;p&gt;We unified them into a single table with a consistent model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ClawProcess&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// 'docker:claw-whisper', 'systemd:postgresql', 'internal:scheduler'&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// 'Speech-to-Text (Whisper)', 'PostgreSQL', 'Job Scheduler'&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;docker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;systemd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;internal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;running&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stopped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;starting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;cpu_percent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;memory_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;gpu_percent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;vram_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;uptime_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;restartable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;stoppable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;has_logs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; format &lt;code&gt;type:name&lt;/code&gt; makes every process globally addressable. &lt;code&gt;docker:claw-mailu-imap&lt;/code&gt; is the IMAP container. &lt;code&gt;systemd:postgresql&lt;/code&gt; is the database. &lt;code&gt;internal:async-queue&lt;/code&gt; is the background job processor. One namespace, one table, one API.&lt;/p&gt;

&lt;p&gt;Three collectors feed the table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Collector&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;td&gt;Docker Engine API&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GET /containers/json&lt;/code&gt; via Unix socket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;systemd&lt;/td&gt;
&lt;td&gt;systemctl&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list-units --type=service --output=json&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal&lt;/td&gt;
&lt;td&gt;Claw Core process&lt;/td&gt;
&lt;td&gt;In-memory state from scheduler, queue, WS server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus a special case: Ollama. It's a systemd service, but it also has loaded models that consume VRAM independently. The process manager queries Ollama's API (&lt;code&gt;GET /api/ps&lt;/code&gt;) to show which models are loaded, their parameter count, and VRAM usage. You can unload a model directly from the process table to free VRAM.&lt;/p&gt;

&lt;p&gt;&lt;a href="/images/process-manager.webp" class="article-body-image-wrapper"&gt;&lt;img src="/images/process-manager.webp" alt="The unified process table — Docker containers, systemd services, internal workers and Ollama models in one namespace, with a live summary bar across the top and a per-process log panel on the right."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The summary bar
&lt;/h2&gt;

&lt;p&gt;The top of the process manager shows system-wide stats at a glance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PROCESSES 16/40    CPU 0.5%    MEMORY 3205 MB    GPU 4%    VRAM 5579 / 16311 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;16 out of 40 registered processes are currently running. Total CPU across all processes. Total memory. GPU utilization and VRAM usage (critical when you're running AI models locally).&lt;/p&gt;

&lt;p&gt;The VRAM indicator is particularly useful. When Ollama loads &lt;code&gt;qwen3.5:4b&lt;/code&gt;, it consumes 5.4GB of VRAM. If someone tries to load a second model and there isn't enough VRAM, the process manager shows exactly why. No more mysterious "out of memory" errors — the dashboard tells you what's using what.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lifecycle policies: not everything needs to be always-on
&lt;/h2&gt;

&lt;p&gt;The real power isn't the table view — it's deciding what runs and when. Each managed service has a lifecycle policy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;When it runs&lt;/th&gt;
&lt;th&gt;Example services&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Always-on&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24/7, never auto-stopped&lt;/td&gt;
&lt;td&gt;PostgreSQL, Claw Core, mail server, WireGuard, Tailscale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;On-demand&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Starts when the user navigates to the module, stops after idle timeout&lt;/td&gt;
&lt;td&gt;ONLYOFFICE, Stirling PDF, Portainer, SolidInvoice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scheduled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runs during configured time windows or intervals&lt;/td&gt;
&lt;td&gt;Jellyfin (08:00-01:00), RSS fetcher (every 60 min)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Triggered&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Starts on a specific event, stops when the task completes&lt;/td&gt;
&lt;td&gt;yt-dlp (download requested), claw-vision (photo uploaded)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In a real deployment, the breakdown looks like this: 5 always-on services, 22 on-demand, 8 scheduled. The 22 on-demand services are the key — they represent ~70% of total potential memory usage, but most of the time only 2-3 of them are actually running.&lt;/p&gt;

&lt;p&gt;When you open the Office module in the dashboard, the system starts ONLYOFFICE (if it's not already running). The loading state shows a progress spinner. After 30 minutes of no document activity, ONLYOFFICE stops automatically. You never think about it.&lt;/p&gt;

&lt;p&gt;Each service's policy is configurable in Settings → Services. Click on a service to expand its configuration: policy selector (always/scheduled/on-demand/disabled), schedule windows, timeout settings, RAM limits. Presets cover common patterns ("Diurnal" for services that run during working hours, "Periodic fetch" for services that sync on intervals).&lt;/p&gt;

&lt;h2&gt;
  
  
  The RAM budget engine
&lt;/h2&gt;

&lt;p&gt;Here's the core problem: 40 services can't all run on 8GB of RAM. Something has to decide.&lt;/p&gt;

&lt;p&gt;The RAM budget engine works with hardware profiles:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Budget for services&lt;/th&gt;
&lt;th&gt;Max concurrent on-demand&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lite&lt;/td&gt;
&lt;td&gt;≤ 6 GB&lt;/td&gt;
&lt;td&gt;~1.5 GB&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;8-14 GB&lt;/td&gt;
&lt;td&gt;~3.3 GB&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance&lt;/td&gt;
&lt;td&gt;16+ GB&lt;/td&gt;
&lt;td&gt;~8 GB&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The profile is auto-detected from system RAM and determines the budget — how much memory is available for managed Docker services (excluding always-on core services like PostgreSQL and Claw Core, which are non-negotiable).&lt;/p&gt;

&lt;p&gt;Before starting any on-demand service, the engine checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can this service fit in the remaining budget?
├── Yes → Start it
└── No → Is there an idle on-demand service we can evict?
    ├── Yes → Stop the longest-idle service, then start the new one
    └── No → Tell the user: "Not enough memory. Stop X to start Y."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eviction targets the longest-idle on-demand service. Always-on services are never evicted — the system won't kill your mail server to make room for a PDF converter. The user always has the final say: the engine suggests what to evict, but it doesn't force it.&lt;/p&gt;

&lt;p&gt;Under memory pressure (free RAM drops below 10%), the engine gets more aggressive: it progressively stops idle on-demand services until pressure is relieved, notifying the user via WebSocket for each one.&lt;/p&gt;

&lt;p&gt;The RAM budget view in Settings shows this live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RAM Budget: 260 / 3335 MB (8%)
├── wg-easy: 11 MB
├── mailu: 57 MB
├── wyoming-whisper: 177 MB
└── wyoming-piper: 15 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;260MB used out of a 3,335MB budget. Room for several more on-demand services before the budget gets tight.&lt;/p&gt;

&lt;p&gt;&lt;a href="/images/process-manager-ram-budget.webp" class="article-body-image-wrapper"&gt;&lt;img src="/images/process-manager-ram-budget.webp" alt="Settings → Services: the auto-detected hardware profile, the live RAM budget bar, and the managed services grouped by lifecycle policy (all / always-on / on-demand / scheduled) with per-service RAM limits and idle timeouts."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Drain guards: stopping safely
&lt;/h2&gt;

&lt;p&gt;You can't just &lt;code&gt;docker stop&lt;/code&gt; a service and hope for the best. ONLYOFFICE might have a document open. Qbittorrent might be mid-download. The terminal might have an active SSH session.&lt;/p&gt;

&lt;p&gt;Drain guards check safety before stopping:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Guard&lt;/th&gt;
&lt;th&gt;What it checks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ONLYOFFICE&lt;/td&gt;
&lt;td&gt;Document sessions&lt;/td&gt;
&lt;td&gt;Are there open documents? → Auto-save first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qbittorrent&lt;/td&gt;
&lt;td&gt;Active downloads&lt;/td&gt;
&lt;td&gt;Is anything downloading? → Wait or warn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminal&lt;/td&gt;
&lt;td&gt;SSH sessions&lt;/td&gt;
&lt;td&gt;Are there active sessions? → Warn user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jellyfin&lt;/td&gt;
&lt;td&gt;Active streams&lt;/td&gt;
&lt;td&gt;Is anyone watching? → Warn user&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If a drain guard blocks a stop operation, the user gets a notification: "ONLYOFFICE has 2 documents open. Save and close them, or force stop." Force stop is always available — but the system tries to prevent data loss by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ollama: the special case
&lt;/h2&gt;

&lt;p&gt;Ollama deserves its own section because it's neither a simple Docker container nor a systemd service — it's both. The systemd service runs the Ollama daemon. The models loaded into it consume VRAM independently of the daemon's RAM usage.&lt;/p&gt;

&lt;p&gt;The process manager handles Ollama differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shows the Ollama daemon as a systemd process with its own CPU/RAM stats&lt;/li&gt;
&lt;li&gt;Below it, shows loaded models in an expandable section: model name, parameter count, RAM usage, VRAM usage&lt;/li&gt;
&lt;li&gt;Each model has an "Unload" button that calls &lt;code&gt;DELETE /api/generate&lt;/code&gt; to free VRAM&lt;/li&gt;
&lt;li&gt;The summary bar includes VRAM as a separate metric from RAM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our current setup, &lt;code&gt;qwen3.5:4b&lt;/code&gt; sits loaded with 5.43GB of VRAM. When we need to load a different model (say, for a vision task), the process manager shows the VRAM impact before loading. If VRAM is full, you can unload the current model first.&lt;/p&gt;

&lt;p&gt;The Ollama client in Claw Core (the priority queue singleton from post 2) manages model loading automatically — it loads models on demand and unloads them based on idle time. The process manager gives you visibility into what it's doing and manual override when you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log panel
&lt;/h2&gt;

&lt;p&gt;Every process with &lt;code&gt;has_logs: true&lt;/code&gt; gets a log viewer. Click the logs icon on any process row and a side panel opens with the last 200 lines, auto-scrolling, with a filter input for grep-style searching.&lt;/p&gt;

&lt;p&gt;Docker logs come from the Docker API (&lt;code&gt;GET /containers/{id}/logs&lt;/code&gt;). Systemd logs come from &lt;code&gt;journalctl -u {service}&lt;/code&gt;. Internal workers log to pino (the Fastify logger) and their output is captured from the running process.&lt;/p&gt;

&lt;p&gt;The filter is surprisingly useful for debugging. When Mailu's IMAP container is acting up, filtering for "error" or "failed" in the log panel is faster than SSH-ing into the server and running &lt;code&gt;docker logs | grep&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approval integration
&lt;/h2&gt;

&lt;p&gt;Process lifecycle operations integrate with the approval system (post 11):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Approval level&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;View processes, logs&lt;/td&gt;
&lt;td&gt;Level 0 (auto)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Start a service&lt;/td&gt;
&lt;td&gt;Level 0 (auto)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restart a service&lt;/td&gt;
&lt;td&gt;Level 2 (confirm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop a service&lt;/td&gt;
&lt;td&gt;Level 2 (confirm)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Starting is unrestricted because the RAM budget engine already gates it — if there's no budget, it won't start. Restart and stop require confirmation because they affect running services. The agent can restart a crashed container, but only after asking "claw-mailu-imap is failing, should I restart it?"&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers look like in practice
&lt;/h2&gt;

&lt;p&gt;On our development machine (8GB RAM, RTX 2060):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Typical RAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Always-on (core)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;~400 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Always-on (mail)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;~400 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Always-on (network)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;~50 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Always-on (AI)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;~200 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On-demand (active)&lt;/td&gt;
&lt;td&gt;2-3&lt;/td&gt;
&lt;td&gt;~300-500 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Systemd&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;~100 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal workers&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;~50 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total running at any given time: ~15-20 processes, ~1.5-2 GB. Well within the Standard profile's 3.3GB budget. The other 20+ on-demand services wait in Docker's stopped state, consuming zero RAM.&lt;/p&gt;

&lt;p&gt;Peak usage (everything on-demand activated simultaneously): ~4.5 GB. This never actually happens — it would require opening every module in the dashboard at the same time. The lifecycle policies ensure only what's needed is running.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I'd build the RAM budget engine before adding any managed services.&lt;/strong&gt; We added services first (mail, PDF, office, media) and the budget engine later. During the gap, the machine would occasionally OOM because too many containers were running. The budget engine should have been the foundation, not an afterthought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd add a "service groups" concept.&lt;/strong&gt; Right now, Mailu is 6 separate containers (IMAP, SMTP, antispam, admin, Redis, frontend). They start and stop together, but the process table shows them individually. A group like "Mail Server" that expands to show its 6 components would reduce visual noise from 40 rows to ~20.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd expose the process table via the agent.&lt;/strong&gt; Currently, the agent can restart and stop services via the HAL API, but it can't see the process table the way the dashboard shows it. A &lt;code&gt;claw-processes&lt;/code&gt; skill with &lt;code&gt;?format=compact&lt;/code&gt; would let the agent answer "what's using all my RAM?" with actual data instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A self-hosted personal OS isn't one service — it's 40 services pretending to be one. The user should never think about which containers are running, how much RAM they're using, or when to start and stop them. That's the process manager's job.&lt;/p&gt;

&lt;p&gt;Three components make it work: a unified process table (Docker + systemd + internal + Ollama, one namespace), a RAM budget engine (hardware profiles, eviction logic, pressure detection), and lifecycle policies (always-on, on-demand, scheduled, triggered).&lt;/p&gt;

&lt;p&gt;The result: 40 services on 8GB of RAM, with the user seeing a single coherent system. The process manager is the plumbing that makes "personal OS" feel like an OS instead of a collection of Docker containers.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next up: InsightFace face recognition on a mini-PC — how we built a face detection pipeline that clusters people across your photo library using a 2B model and a Python sidecar, all running on CPU.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosted</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>Designing an AI approval system: when should your agent ask for permission?</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Tue, 07 Apr 2026 10:38:11 +0000</pubDate>
      <link>https://dev.to/micelclaw/designing-an-ai-approval-system-when-should-your-agent-ask-for-permission-k94</link>
      <guid>https://dev.to/micelclaw/designing-an-ai-approval-system-when-should-your-agent-ask-for-permission-k94</guid>
      <description>&lt;p&gt;An AI agent that can only read data is safe but useless. An AI agent that can send emails, delete files, format disks, and configure VPNs is useful but terrifying. The entire value of a personal AI operating system comes from the agent acting on your behalf — and the entire risk comes from the same thing.&lt;/p&gt;

&lt;p&gt;We needed a system that says "yes" fast to everyday operations and "are you sure?" to dangerous ones. Not a blanket confirmation on everything (that just trains the user to click "approve" without reading). Not unrestricted access either (one prompt injection away from &lt;code&gt;rm -rf /&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;This post is about the 4-level approval system we built, the dual-layer architecture (shell + API), and the surprisingly difficult design decision of where to draw the line between "just do it" and "ask me first."&lt;/p&gt;

&lt;h2&gt;
  
  
  The two attack surfaces
&lt;/h2&gt;

&lt;p&gt;An AI agent in our system can cause damage in two completely different ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shell execution.&lt;/strong&gt; The agent uses the runtime's &lt;code&gt;exec&lt;/code&gt; tool to run commands on the host machine. This is raw power — &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt;, but also &lt;code&gt;rm&lt;/code&gt;, &lt;code&gt;dd&lt;/code&gt;, &lt;code&gt;python3 -c 'import os; os.system("...")'&lt;/code&gt;. The attack surface is the entire operating system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API operations.&lt;/strong&gt; The agent calls our REST API via &lt;code&gt;curl&lt;/code&gt;. The &lt;code&gt;curl&lt;/code&gt; command itself is harmless — it's the endpoint that's dangerous. &lt;code&gt;POST /storage/pools&lt;/code&gt; creates a RAID array. &lt;code&gt;DELETE /files/:id&lt;/code&gt; removes a file. &lt;code&gt;POST /emails/send&lt;/code&gt; sends an email you can't unsend. The business logic is the attack surface.&lt;/p&gt;

&lt;p&gt;These need separate control mechanisms because they have different risk profiles and different mitigation strategies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: Shell control
&lt;/h2&gt;

&lt;p&gt;By default, the agent can only execute a small set of safe binaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Safe bins: curl, jq, cat, echo, date, wc, head, tail, grep
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The agent talks to the outside world through &lt;code&gt;curl&lt;/code&gt; to our REST API. Everything else — file manipulation, package installation, network commands, scripting — is blocked at the runtime level.&lt;/p&gt;

&lt;p&gt;There's an "Unrestricted Shell Mode" toggle in Settings → Security. It's deliberately scary: the toggle is marked in red, requires the user's password (not just a click), and shows a warning explaining that this allows the agent to execute any command on the system.&lt;/p&gt;

&lt;p&gt;Even in unrestricted mode, destructive commands (&lt;code&gt;rm&lt;/code&gt;, &lt;code&gt;dd&lt;/code&gt;, &lt;code&gt;mkfs&lt;/code&gt;, &lt;code&gt;fdisk&lt;/code&gt;) always require per-operation confirmation from the user. Full freedom doesn't mean no guardrails — it means the agent can attempt anything, but the user decides on dangerous operations.&lt;/p&gt;

&lt;p&gt;The key design principle: most users never enable unrestricted mode. The agent does everything it needs through the API. Shell access is a power-user feature for people who know what they're doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: Operation approvals
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. Every API operation has an approval level:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Auto&lt;/td&gt;
&lt;td&gt;Execute immediately, no record. Reads, searches, listings.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Logged&lt;/td&gt;
&lt;td&gt;Execute immediately, log to audit trail. Creates, updates, non-destructive writes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Confirm&lt;/td&gt;
&lt;td&gt;Pause and ask the user "Are you sure?" before executing. Sends, deletes, infrastructure changes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Secure&lt;/td&gt;
&lt;td&gt;Pause, ask for confirmation AND a numeric PIN. Format disk, delete volume, system reset.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The approval level is checked by a Fastify middleware (preHandler) that runs before the route handler. If the request comes from an agent token and the operation requires Level 2+, the middleware returns a &lt;code&gt;202 Accepted&lt;/code&gt; with an &lt;code&gt;approval_id&lt;/code&gt; instead of executing the operation. The agent then asks the user for confirmation through whatever channel they're chatting on — Telegram, WhatsApp, or the web dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  How it looks in practice
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Level 0 — Auto (reading notes):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"What notes do I have about the project?"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/notes?search=project&amp;amp;format=compact&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Executes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;immediately.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;User&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sees&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;results.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No friction. Reads are always auto.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 1 — Logged (creating a note):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Save a note about today's meeting decisions"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/notes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;title:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Meeting decisions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;content:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Executes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;immediately.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Logged&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;audit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;trail.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still no friction — the user asked for it. But the audit trail records that agent &lt;code&gt;francis&lt;/code&gt; created note &lt;code&gt;abc123&lt;/code&gt; at 14:32. If something goes wrong, there's accountability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 2 — Confirm (sending an email):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Send Ana the budget update"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/emails/send&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;to:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ana@techcorp.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;subject:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Budget Q3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Accepted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;approval_id:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_xyz"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I'm about to send this email to Ana García:
        Subject: Budget Q3
        [Preview of the body]
        Should I send it?"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Yes"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/approvals/req_xyz/approve&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sent.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One confirmation step. The agent shows what it's about to do. The user says yes or no. This catches the common case where the agent misunderstood the intent — "I said Ana, not María" — without making every email a five-step process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 3 — Secure (deleting a volume):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Delete the old backup volume"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/storage/volumes/vol_old/delete&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Accepted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;approval_id:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_abc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;level:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"⚠️ This will permanently delete volume vol_old (2.3TB).
        This cannot be undone.
        Please confirm with your security PIN."&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4829"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/approvals/req_abc/approve&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;pin:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4829"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Volume&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;deleted.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two-factor: the user confirms AND enters their PIN. The PIN is a 4-6 digit numeric code set during initial setup, stored hashed with bcrypt. In messaging channels, the PIN message is deleted from chat history after verification (when the channel supports it).&lt;/p&gt;

&lt;h2&gt;
  
  
  The approval lifecycle
&lt;/h2&gt;

&lt;p&gt;Every Level 2+ operation creates an &lt;code&gt;approval_request&lt;/code&gt; record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;approval_requests&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;         &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;requested_by&lt;/span&gt;    &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'agent:francis'&lt;/span&gt;
    &lt;span class="k"&gt;operation&lt;/span&gt;       &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'POST /emails/send'&lt;/span&gt;
    &lt;span class="k"&gt;level&lt;/span&gt;           &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- 2 or 3&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;-- "Send email to Ana: Budget Q3"&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;          &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;-- Request body snapshot&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;          &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resolved_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pin_verified&lt;/span&gt;    &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt;      &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;      &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lifecycle is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6qbx1xet8glw8nzh3kwz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6qbx1xet8glw8nzh3kwz.webp" alt="Timeouts, reminder, escalation, expiry"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent triggers operation
    ↓
Middleware: level &amp;gt;= 2? → Create approval_request (status: pending)
    ↓
Notify user via WebSocket (Dash) + Gateway RPC (Telegram/WhatsApp)
    ↓
User approves, rejects, or ignores
    ↓
├── Approved → Execute operation, status: approved
├── Rejected → Return error to agent, status: rejected
└── Timeout (30min) → Auto-expire, status: expired
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three timeout stages prevent approvals from hanging forever:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reminder&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;Send a reminder to the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escalation&lt;/td&gt;
&lt;td&gt;15 minutes&lt;/td&gt;
&lt;td&gt;Notify the system owner (if different from the user)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expiry&lt;/td&gt;
&lt;td&gt;30 minutes&lt;/td&gt;
&lt;td&gt;Auto-reject the request&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three are configurable in Settings → Security → Approval Timeouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's configurable and what isn't
&lt;/h2&gt;

&lt;p&gt;The default levels are sensible but not everyone agrees on what's "dangerous." A power user who sends 50 emails a day wants email sending at Level 1 (logged, no confirmation). A cautious user wants it at Level 2 (confirm every one).&lt;/p&gt;

&lt;p&gt;Settings → Security shows a table of operations with dropdown selectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Operation                    Default    Your Level
─────────────────────────────────────────────────
Create note                  Logged     [1 - Logged ▼]
Send email                   Confirm    [2 - Confirm ▼]
Delete files (permanent)     Confirm    [2 - Confirm ▼]
Enable VPN                   Confirm    [2 - Confirm ▼]
Add VPN peer                 Confirm    [2 - Confirm ▼]
Delete volume                Secure     [3 - Secure  ▼]
Format disk                  Secure     [3 - Secure  ▼]
System reset                 Secure     [3 - Secure  ▼]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two constraints prevent dangerous configurations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Level 3 operations can't go below Level 2.&lt;/strong&gt; You can downgrade "Delete volume" from Secure (3) to Confirm (2), but not to Logged (1) or Auto (0). Destructive, irreversible operations always require at least one confirmation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Level 0 operations can't be upgraded.&lt;/strong&gt; Read operations are always auto. Making &lt;code&gt;GET /notes&lt;/code&gt; require confirmation would break the system — the agent would need approval to answer "what notes do I have?"&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Changing approval levels is itself a Level 2 operation — the system asks for confirmation before letting you change the security settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents can't approve their own requests
&lt;/h2&gt;

&lt;p&gt;This sounds obvious but it's the most important security decision in the system: &lt;strong&gt;an agent API key cannot approve an approval request.&lt;/strong&gt; Only JWT tokens (human login via Dash) or system tokens can call &lt;code&gt;POST /approvals/:id/approve&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the agent could approve its own requests, a prompt injection attack could chain: trigger the operation → intercept the approval request → approve it. The human-in-the-loop is only meaningful if the human is the one doing the approving.&lt;/p&gt;

&lt;p&gt;In messaging channels (Telegram, WhatsApp), the approval flows through the agent — the user says "yes" in the chat, and the agent calls the approve endpoint. But the approve endpoint verifies that the approval was triggered by a user message, not by the agent itself. The &lt;code&gt;requested_by&lt;/code&gt; field records which agent requested the operation, and the same agent cannot resolve it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The middleware: 15 lines that matter
&lt;/h2&gt;

&lt;p&gt;The approval check is a Fastify preHandler that runs on every route with an assigned level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified — the real version handles edge cases&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;approvalMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Skip for human (JWT) requests — the Dash IS the confirmation&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jwt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getOperationLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Auto or Logged — proceed&lt;/span&gt;

  &lt;span class="c1"&gt;// Level 2 or 3: create approval request and pause&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;approval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createApprovalRequest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;requestedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`agent:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKeyName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Notify user&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifyApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Return 202 — the agent knows to ask the user&lt;/span&gt;
  &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;approval_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; 
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Ask user to confirm with PIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; 
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Ask user to confirm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;hint&lt;/code&gt; field in the 202 response tells the agent's skill what kind of confirmation to request. Level 2: ask for a yes/no. Level 3: ask for the PIN.&lt;/p&gt;

&lt;p&gt;Human requests from the Dash skip the middleware entirely. When you click "Send" in the email composer, you ARE the confirmation. The approval system only gates agent-initiated operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The skill: teaching the agent to ask
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;claw-approvals&lt;/code&gt; skill teaches the agent how to handle the approval flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Approval Protocol&lt;/span&gt;

When you receive a 202 response with an approval_id:
&lt;span class="p"&gt;1.&lt;/span&gt; Show the user what you're about to do (use the summary field)
&lt;span class="p"&gt;2.&lt;/span&gt; For Level 2: ask "Should I proceed?"
&lt;span class="p"&gt;3.&lt;/span&gt; For Level 3: ask "Please confirm with your security PIN"
&lt;span class="p"&gt;4.&lt;/span&gt; On "yes" or PIN: POST /approvals/{id}/approve (with pin if Level 3)
&lt;span class="p"&gt;5.&lt;/span&gt; On "no" or "cancel": POST /approvals/{id}/reject
&lt;span class="p"&gt;6.&lt;/span&gt; Never approve your own requests — always wait for user input
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skill also handles &lt;code&gt;/pending&lt;/code&gt; (show pending approvals), &lt;code&gt;/history&lt;/code&gt; (show past approvals), and edge cases like expired requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned about where to draw the line
&lt;/h2&gt;

&lt;p&gt;The hardest part wasn't building the system — it was deciding which operations go at which level. Some were obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read anything → Level 0.&lt;/strong&gt; No debate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format disk → Level 3.&lt;/strong&gt; No debate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The middle ground is where every conversation happened:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email sending: Level 1 or Level 2?&lt;/strong&gt; We went with Level 2 (Confirm) as default. Email is irreversible — you can't unsend it. A misunderstood intent ("send Ana the budget" when you meant "draft Ana the budget") has real consequences. But we made it configurable because power users find the confirmation annoying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Creating a note: Level 0 or Level 1?&lt;/strong&gt; We went with Level 1 (Logged). Creating a note is harmless — but logging it means the audit trail shows everything the agent did. If the agent starts creating garbage notes due to a bug, the log tells you when it started. Zero friction, full accountability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPN operations: Level 2.&lt;/strong&gt; Enabling or disabling VPN changes network topology. Adding a peer grants network access to a device. These aren't destructive (you can undo them) but they affect security posture. Confirmation is appropriate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting files: Level 2, not Level 3.&lt;/strong&gt; Files have soft delete with restore capability. "Delete" doesn't mean "gone forever" — it means "moved to trash." If deletion were permanent (bypassing soft delete), it would be Level 3.&lt;/p&gt;

&lt;p&gt;The general heuristic: &lt;strong&gt;reversible actions need less approval than irreversible ones.&lt;/strong&gt; Creating is Level 1 (you can delete it). Sending is Level 2 (you can't unsend it). Destroying is Level 3 (it's gone forever).&lt;/p&gt;

&lt;h2&gt;
  
  
  The dual-layer interaction
&lt;/h2&gt;

&lt;p&gt;The two layers (shell + API) work together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Check my disk space"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;exec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"df -h"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Layer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"df"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;safe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;bins&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Execute&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;immediately&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/storage/overview?format=compact&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Layer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Level&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(read)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Execute&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;immediately&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"You have 450GB free on /data. The backup volume is 78% full."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No approvals needed — both layers allowed the operations silently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Set up a RAID1 mirror with the two new drives"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/storage/pools&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;name:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mirror1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;level:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"raid1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;devices:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sda"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sdb"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Layer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Level&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(Confirm)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Accepted&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I'm about to create a RAID1 pool using sda and sdb. 
        This will erase all data on both drives. Confirm?"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Yes"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Pool&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;created.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in unrestricted shell mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Install htop on the server"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;exec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"apt-get install htop"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Layer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;safe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;bins&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;unrestricted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Ask&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;confirmation&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I want to run: apt-get install htop
        This will install a package on your system. OK?"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Go ahead"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Layer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Approved&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Execute&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both layers enforce independently. An operation that passes Layer 1 (shell) can still be blocked by Layer 2 (API). An operation that bypasses Layer 2 (because it's a direct shell command) is still caught by Layer 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I'd add batch approvals from the start.&lt;/strong&gt; When the agent needs to send 15 emails from a mail merge, asking for 15 individual confirmations is absurd. A "batch approve" mechanism ("Send these 15 emails? Here's the list.") should have been in v1. It's now on the backlog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd make the approval history more visible.&lt;/strong&gt; The audit trail exists (&lt;code&gt;GET /approvals/history&lt;/code&gt;), and there's an "Approvals History" section in the sidebar, but it should be more prominent. A weekly summary — "Your agent executed 340 operations this week: 280 auto, 55 logged, 5 confirmed" — would build trust and help users understand their agent's activity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd reconsider the messaging channel PIN flow.&lt;/strong&gt; Sending a PIN via Telegram is not ideal — it's visible in chat history even if the agent tries to delete the message. For Level 3 operations, maybe the system should redirect to the Dash where a secure input modal exists, rather than accepting PINs in plain text through messaging.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next: the sandbox
&lt;/h2&gt;

&lt;p&gt;There's a missing piece between "restricted mode" (curl only) and "unrestricted mode" (everything, with confirmations). What if the agent could have a place to go completely wild — install packages, run services, break things — without any risk to your real system?&lt;/p&gt;

&lt;p&gt;We're planning &lt;strong&gt;sandbox environments&lt;/strong&gt;: Docker containers that the agent can create from Settings → Security. Not one — as many as you need. Each sandbox is an isolated machine with full root access. &lt;code&gt;apt install&lt;/code&gt;, &lt;code&gt;pip install&lt;/code&gt;, &lt;code&gt;systemctl&lt;/code&gt;, custom scripts, databases, web servers — anything goes. Zero approval, zero restrictions, zero risk to the host.&lt;/p&gt;

&lt;p&gt;The workflow we're designing around:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;Sandbox "deploy-test"     → experimenting with nginx + certbot config&lt;/span&gt;
&lt;span class="s"&gt;Sandbox "ml-pipeline"     → building a data processing pipeline with pandas&lt;/span&gt;
&lt;span class="s"&gt;Sandbox "new-skill"       → developing and testing a new agent skill&lt;/span&gt;

&lt;span class="na"&gt;Each one&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;independent, disposable, unrestricted.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent knows which sandbox it's working in. Commands routed to a sandbox go to that container. Commands on the real system go through normal approval layers. The two worlds don't touch — no shared volumes, no network bridge to internal services, no mount to &lt;code&gt;/data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The key is the &lt;strong&gt;promote-to-production&lt;/strong&gt; flow. Once you've got something working in the sandbox — a configuration, a script, a service setup — you tell the agent "promote this to production." At that point, and only at that point, normal approval rules kick in. The agent needs Level 2 confirmation to copy files to the host, Level 2 to install a package on the real system, Level 3 to modify infrastructure. The sandbox is the drafting table; production is the real thing.&lt;/p&gt;

&lt;p&gt;If a sandbox gets trashed, nuke it and spawn a fresh one in seconds. The sandboxes are cheap — a base Debian image with internet access and a persistent volume for the workspace. Multiple sandboxes can run simultaneously for different experiments without interfering with each other or with the host.&lt;/p&gt;

&lt;p&gt;This isn't implemented yet, and there are open design questions: should sandboxes have read-only access to the real API (for testing skills against real data)? Should there be resource limits per sandbox (CPU, RAM, disk)? What's the UX for promoting — file-by-file or snapshot the whole container? We'd love input from anyone who's built agent sandboxing — this is genuinely uncharted territory for personal AI systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;An AI approval system needs two properties: it must be &lt;strong&gt;fast for safe operations&lt;/strong&gt; (no friction on reads, minimal friction on writes) and &lt;strong&gt;deliberate for dangerous ones&lt;/strong&gt; (explicit confirmation, PIN for irreversible actions, timeout for stale requests).&lt;/p&gt;

&lt;p&gt;Four levels handle this: auto (reads), logged (writes), confirm (irreversible), secure (destructive + PIN). Two layers: shell control for host-level commands, API control for business operations. One principle: agents cannot approve their own requests.&lt;/p&gt;

&lt;p&gt;The system processes ~95% of operations at Level 0 or 1 — invisible to the user. The 5% that require confirmation are the operations where a mistake actually matters: sending an email to the wrong person, deleting a volume, configuring network access. Those 5% are where trust is built or broken.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: building a process manager — how we manage Docker containers, systemd services, and Ollama models from a single dashboard with auto-start, health monitoring, and graceful shutdown.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>architecture</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>PII-aware routing: how to use cloud AI and keep your sensitive data local</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Fri, 27 Mar 2026 14:23:21 +0000</pubDate>
      <link>https://dev.to/micelclaw/pii-aware-routing-how-to-use-cloud-ai-and-keep-your-sensitive-data-local-1m40</link>
      <guid>https://dev.to/micelclaw/pii-aware-routing-how-to-use-cloud-ai-and-keep-your-sensitive-data-local-1m40</guid>
      <description>&lt;p&gt;Here's the tension at the heart of every personal AI system: cloud models are better at reasoning, but your data is private. A self-hosted system can run everything locally — but a 2B parameter model on a mini-PC isn't going to draft a nuanced email response or analyze a complex financial situation the way a frontier model can.&lt;/p&gt;

&lt;p&gt;The naive solutions are both bad. "Send everything to the cloud" means your diary entries, medical notes, and financial records pass through someone else's servers. "Run everything locally" means accepting worse reasoning on tasks where model quality actually matters.&lt;/p&gt;

&lt;p&gt;We built a third option: a PII-aware routing layer that classifies every piece of data by sensitivity, routes it to the right model, and pseudonymizes anything sensitive that needs cloud reasoning power.&lt;/p&gt;

&lt;h2&gt;
  
  
  The classification: four levels, zero LLM calls
&lt;/h2&gt;

&lt;p&gt;Every record in the system gets a sensitivity level. The classification is entirely deterministic — regex patterns and domain rules. No LLM in the classification loop, because sending data to an LLM to decide if the data is too sensitive to send to an LLM is a circular problem.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Example domains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public or low-risk data&lt;/td&gt;
&lt;td&gt;Events, bookmarks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;normal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Common personal data&lt;/td&gt;
&lt;td&gt;Notes, contacts, files, diary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sensitive personal data&lt;/td&gt;
&lt;td&gt;Emails, financial transactions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;critical&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never leaves the device&lt;/td&gt;
&lt;td&gt;Medical/health data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each domain has a default sensitivity level. Events are &lt;code&gt;low&lt;/code&gt; — knowing you have a meeting at 3pm isn't particularly sensitive. Emails are &lt;code&gt;high&lt;/code&gt; — they contain names, addresses, business context, and sometimes confidential information. Health entries are &lt;code&gt;critical&lt;/code&gt; — always local, no exceptions.&lt;/p&gt;

&lt;p&gt;But domains are just the baseline. The classifier also scans content for PII patterns that override the default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Email addresses     → elevate to high minimum
Phone numbers       → elevate to high minimum
Credit card numbers → elevate to high minimum
IBAN codes          → elevate to high minimum
SSN / DNI / NIE     → elevate to high minimum
Medical terminology → elevate to critical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note titled "Grocery list" stays at &lt;code&gt;normal&lt;/code&gt;. A note containing "Dr. García prescribed 20mg omeprazole" gets elevated to &lt;code&gt;critical&lt;/code&gt; because the regex matched medical terminology. The content drives the classification, not just the domain.&lt;/p&gt;

&lt;p&gt;This is deliberately conservative. The regex patterns over-match — "Dr." triggers medical detection even if it's "Dr. Pepper." False positives mean data gets routed locally when it could have gone to the cloud. False negatives mean sensitive data leaks. Over-matching is the correct failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The routing decision
&lt;/h2&gt;

&lt;p&gt;Once classified, the router decides where each piece of data goes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;low / normal  → Cloud LLM — best reasoning
high          → Cloud LLM WITH pseudonymization — good reasoning, protected data
critical      → Local model only (Ollama) — or skip if Ollama unavailable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decision isn't binary "local vs cloud." There's a middle path: pseudonymize the sensitive parts, send to the cloud for reasoning, and de-pseudonymize the response before the user sees it.&lt;/p&gt;

&lt;p&gt;This matters because most tasks involving sensitive data don't need the sensitive parts for reasoning. "Summarize this email thread" needs the content structure and topic — not the actual names and email addresses. "What's the sentiment of this diary entry?" needs the emotional content — not the specific people mentioned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pseudonymizer
&lt;/h2&gt;

&lt;p&gt;When a &lt;code&gt;high&lt;/code&gt; sensitivity record needs cloud processing, the pseudonymizer replaces PII with consistent tokens:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entity type&lt;/th&gt;
&lt;th&gt;Pseudonym format&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Person&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Person_XXXX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Ana García" → &lt;code&gt;Person_A3F2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;&lt;code&gt;email_XXXX@example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"&lt;a href="mailto:ana@techcorp.com"&gt;ana@techcorp.com&lt;/a&gt;" → &lt;code&gt;email_7B1C@example.com&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+00-XXXX-0000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"+34 612 345 678" → &lt;code&gt;+00-E5D9-0000&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organization&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Org_XXXX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"TechCorp" → &lt;code&gt;Org_4C8A&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Location&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Location_XXXX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Calle Sagasta 15" → &lt;code&gt;Location_B2E1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three properties make this work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistency.&lt;/strong&gt; The same value always produces the same pseudonym (SHA-256 of the original value, truncated). "Ana García" is always &lt;code&gt;Person_A3F2&lt;/code&gt;, in every record, in every session. This means the cloud model can reason about relationships: "Person_A3F2 sent 3 emails to Person_B7D1 about Org_4C8A" preserves the structure even though the names are hidden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reversibility.&lt;/strong&gt; The &lt;code&gt;pseudonym_map&lt;/code&gt; table stores every mapping. When the cloud model's response comes back, the system replaces all pseudonyms with real values before storing or displaying the result. The user never sees &lt;code&gt;Person_A3F2&lt;/code&gt; — they see "Ana García."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistence.&lt;/strong&gt; Mappings survive across sessions. If "Ana García" was pseudonymized yesterday and appears again today, she gets the same pseudonym. This means the cloud model can build consistent context across multiple interactions without ever learning the real name.&lt;/p&gt;

&lt;p&gt;The detection itself uses regex — no LLM call. It's the same NER-lite approach as the sensitivity classifier: pattern matching for emails, phones, card numbers, and named entity patterns for persons and organizations. Not perfect, but fast and deterministic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario 1: Calendar event (low sensitivity)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;User asks: "What's on my calendar tomorrow?"&lt;/p&gt;

&lt;p&gt;The system fetches tomorrow's events. Events are &lt;code&gt;low&lt;/code&gt; sensitivity. The full data — titles, locations, attendees — goes straight to the cloud model. No pseudonymization needed. The model reasons about the schedule and responds with a natural summary.&lt;/p&gt;

&lt;p&gt;Cost: one cloud API call. Privacy: no sensitive data exposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario 2: Email analysis (high sensitivity)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;User asks: "Summarize the email thread about the partnership."&lt;/p&gt;

&lt;p&gt;The email thread is &lt;code&gt;high&lt;/code&gt; sensitivity (default for emails). Before sending to the cloud model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Original: "Ana García &amp;lt;ana@techcorp.com&amp;gt; wrote: Hi Paco, 
regarding the TechCorp partnership with NexaTech..."

Pseudonymized: "Person_A3F2 &amp;lt;email_7B1C@example.com&amp;gt; wrote: 
Hi Person_0D4E, regarding the Org_4C8A partnership with Org_9F3B..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cloud model receives the pseudonymized version. It can still analyze the thread structure, identify that Person_A3F2 is negotiating with Person_0D4E, and summarize the key points. The reasoning quality is nearly identical — the model doesn't need to know the real names to understand the negotiation dynamics.&lt;/p&gt;

&lt;p&gt;The response comes back with pseudonyms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Person_A3F2 proposed a revenue-sharing model with Org_9F3B. 
Person_0D4E agreed in principle but requested..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system de-pseudonymizes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Ana García proposed a revenue-sharing model with NexaTech. 
Paco agreed in principle but requested..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost: one cloud API call + ~2ms pseudonymization. Privacy: no real names or emails left the device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario 3: Health data (critical sensitivity)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;User asks: "What medications am I taking?"&lt;/p&gt;

&lt;p&gt;Health entries are &lt;code&gt;critical&lt;/code&gt;. They never leave the device, period. The system routes to the local Ollama model. If Ollama is unavailable, the query fails gracefully — it does NOT fall back to the cloud.&lt;/p&gt;

&lt;p&gt;The local model's response might be less polished, but for medical data retrieval, the task is usually simple: find the records and list them. A 2B model handles that fine.&lt;/p&gt;

&lt;p&gt;Cost: one local model call. Privacy: absolute — zero data exposure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario 4: Note with accidental PII (elevated sensitivity)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;User creates a note: "Meeting with Dr. López about the lab results. Blood pressure 140/90."&lt;/p&gt;

&lt;p&gt;The note's domain is &lt;code&gt;normal&lt;/code&gt;, but the content contains medical terminology ("Dr.", "lab results", "blood pressure"). The classifier elevates it to &lt;code&gt;critical&lt;/code&gt;. From this point on, this note is treated like health data — local only.&lt;/p&gt;

&lt;p&gt;The user didn't tag it as medical. They didn't configure anything. The system caught it automatically. Conservative false positives are the design choice: if a note mentions "Dr. Pepper," it gets elevated too. That's a minor inconvenience (one note processed locally instead of on the cloud) with zero privacy risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit trail
&lt;/h2&gt;

&lt;p&gt;Every routing decision is logged:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;What it records&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;domain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which data domain (notes, emails, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;record_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which specific record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sensitivity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Classified sensitivity level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What happened: &lt;code&gt;sent_pseudonymized&lt;/code&gt;, &lt;code&gt;sent_plain&lt;/code&gt;, &lt;code&gt;blocked&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;destination&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Where it went: &lt;code&gt;embeddings&lt;/code&gt;, &lt;code&gt;contextual_retrieval&lt;/code&gt;, &lt;code&gt;sleep_time&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;pii_routing_log&lt;/code&gt; table creates a complete audit of what data was exposed to which processing pipeline. If you ever need to answer "did my medical data ever touch a cloud service?", the answer is in the log.&lt;/p&gt;

&lt;p&gt;This is also how we verify the system works correctly. The log shows every routing decision. If a &lt;code&gt;critical&lt;/code&gt; record ever appears with action &lt;code&gt;sent_plain&lt;/code&gt; and a cloud destination, that's a bug — and the log caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where routing applies
&lt;/h2&gt;

&lt;p&gt;PII-aware routing isn't just for chat interactions. It applies everywhere the system sends data to an LLM:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings.&lt;/strong&gt; When generating semantic embeddings, the text is classified before being sent to the embedding model. If you're using a cloud embedding API (future option), &lt;code&gt;high&lt;/code&gt; and &lt;code&gt;critical&lt;/code&gt; records get embedded locally via Ollama instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contextual retrieval.&lt;/strong&gt; The HyDE pipeline (generating hypothetical answers for better search) uses LLM calls. If the search touches sensitive domains, those calls route through the pseudonymizer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sleep-time compute.&lt;/strong&gt; The background intelligence jobs process records during idle periods. The enrichment job (re-extracting entities from hot records) respects the same routing rules — a &lt;code&gt;critical&lt;/code&gt; record only gets re-extracted if Ollama is available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entity extraction.&lt;/strong&gt; When the CRUD hooks pipeline sends text to the LLM for entity extraction, the same classification applies. A health-related note gets extracted locally.&lt;/p&gt;

&lt;p&gt;The routing layer sits between every LLM consumer in the system and the actual model call. It's middleware — invisible to the features that use it, enforced consistently everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multi-agent dimension
&lt;/h2&gt;

&lt;p&gt;With a multi-agent topology (7 agents in our system), PII routing gets another layer: agent scoping.&lt;/p&gt;

&lt;p&gt;Each agent has a scoped token that defines what domains it can access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Francis (main)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;notes:*, events:*, emails:*, contacts:*, diary:*&lt;/span&gt;
&lt;span class="na"&gt;Sentinel (infra)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;storage:*, hal:*, network:*&lt;/span&gt;
&lt;span class="na"&gt;Dalí (creative)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;photos:read, files:read&lt;/span&gt;
&lt;span class="na"&gt;Ledger (finance)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;finance:*, crm:*&lt;/span&gt;
&lt;span class="na"&gt;Darwin (analytics)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;graph:*, insights:*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sentinel can't access emails. Dalí can't read the diary. This is enforced at the API level — even if a prompt injection tricks Dalí into requesting diary entries, the scoped token blocks it.&lt;/p&gt;

&lt;p&gt;Combined with PII routing, this creates defense in depth:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Agent scoping&lt;/strong&gt; prevents access to domains the agent shouldn't touch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sensitivity classification&lt;/strong&gt; catches PII regardless of domain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pseudonymization&lt;/strong&gt; protects data that needs cloud processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit logging&lt;/strong&gt; records everything for verification&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A prompt injection attack would need to bypass all four layers to exfiltrate sensitive data. The scoping blocks the API call. The classification catches the content. The pseudonymizer strips the PII. The audit log records the attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we explicitly don't do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;We don't use ML for classification.&lt;/strong&gt; A fine-tuned classifier could be more accurate than regex patterns. But it would need to see the data to classify it — which means sending potentially sensitive data to a model before deciding if it's safe to send to a model. Regex is dumber but has zero data exposure during classification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We don't redact — we pseudonymize.&lt;/strong&gt; Redaction (&lt;code&gt;[REDACTED]&lt;/code&gt;) destroys information the cloud model needs for reasoning. Pseudonymization preserves structure ("Person_A sent an email to Person_B") while hiding identity. The cloud model can still reason about relationships, quantities, and patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We don't let the user override &lt;code&gt;critical&lt;/code&gt;.&lt;/strong&gt; You can change a record's sensitivity from &lt;code&gt;normal&lt;/code&gt; to &lt;code&gt;high&lt;/code&gt; manually. You cannot downgrade &lt;code&gt;critical&lt;/code&gt; to anything else. Health data stays local regardless of user preferences. This is a deliberate paternalistic choice — the privacy risk of accidentally exposing medical data outweighs the convenience of sending it to a better model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We don't route based on the LLM provider's privacy policy.&lt;/strong&gt; Whether provider A's privacy policy is better than provider B's is irrelevant. The system treats all cloud LLMs identically: external services that should never see &lt;code&gt;critical&lt;/code&gt; data and should only see &lt;code&gt;high&lt;/code&gt; data in pseudonymized form. Trust the math, not the terms of service.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I'd add per-field sensitivity, not just per-record.&lt;/strong&gt; Currently, a contact record is &lt;code&gt;normal&lt;/code&gt; even though the &lt;code&gt;phones&lt;/code&gt; field is arguably more sensitive than the &lt;code&gt;company&lt;/code&gt; field. Per-field classification would let us pseudonymize just the phone number while sending the company name to the cloud. More precise, but also more complex — the pseudonymizer would need to understand JSON field structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd build a sensitivity dashboard earlier.&lt;/strong&gt; The &lt;code&gt;pii_routing_log&lt;/code&gt; has all the data, but there's no UI for it yet. A dashboard showing "this week: 450 records processed, 380 sent plain, 65 pseudonymized, 5 blocked" would build user trust and make the privacy system tangible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd make the regex patterns configurable.&lt;/strong&gt; Different users have different sensitivity needs. A doctor might want "aspirin" to be flagged as medical. A pharmacist might want it treated as normal. The current patterns are one-size-fits-all, which means they're too aggressive for some users and not aggressive enough for others.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The privacy problem in personal AI isn't "local vs cloud." It's "which data goes where." Most of your data is fine to send to a cloud model — your calendar events and bookmark titles aren't secrets. Some data needs protection but can still benefit from cloud reasoning — pseudonymize it and send the structure without the identity. A small fraction of data should never leave your device — route it locally and accept the quality trade-off.&lt;/p&gt;

&lt;p&gt;Three components: a regex classifier (zero LLM calls, deterministic), a SHA-256 pseudonymizer (consistent, reversible, persistent), and a routing table (domain defaults + content elevation). No ML, no fine-tuning, no privacy policy trust assumptions.&lt;/p&gt;

&lt;p&gt;The system processes your medical notes with a 2B local model and your calendar queries with a cloud model. It knows the difference because a regex matched "blood pressure" — not because it asked an AI what's sensitive.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: designing an AI approval system — when should your agent ask for permission, and how do you build a confirmation workflow that doesn't slow everything down?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>ai</category>
      <category>architecture</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Sleep-time compute for personal data: what your AI should do while you sleep</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Thu, 26 Mar 2026 12:01:52 +0000</pubDate>
      <link>https://dev.to/micelclaw/sleep-time-compute-for-personal-data-what-your-ai-should-do-while-you-sleep-13fj</link>
      <guid>https://dev.to/micelclaw/sleep-time-compute-for-personal-data-what-your-ai-should-do-while-you-sleep-13fj</guid>
      <description>&lt;p&gt;Your personal AI assistant sits idle most of the day. You send it a message, it responds, then it waits. For hours. Maybe all night. The compute is there — the model is loaded, the database is running, the server is warm. But nothing happens until you type the next message.&lt;/p&gt;

&lt;p&gt;That's test-time compute: work done when the user asks for it. Letta's research (arXiv:2504.13171) showed that shifting processing to idle periods — sleep-time compute — achieves 5× fewer tokens at test time and 15% more correct answers. But their implementation only processes conversation memory. Nobody had applied it to structured personal data.&lt;/p&gt;

&lt;p&gt;We did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;Instead of the agent doing all its thinking when you ask a question, it does most of the thinking in the background — during idle periods when you're not using the system. When you finally ask "what's going on with Project Tempest?", the answer is already half-assembled.&lt;/p&gt;

&lt;p&gt;The system maintains four background jobs that run every 30 minutes, but only when you're idle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Enrich connections&lt;/td&gt;
&lt;td&gt;Finds hot records with few graph links, re-runs entity extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Generate summary&lt;/td&gt;
&lt;td&gt;Compiles a weekly overview from the change log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Detect patterns&lt;/td&gt;
&lt;td&gt;Discovers entities that co-occur but aren't linked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Update preferences&lt;/td&gt;
&lt;td&gt;Learns behavioral patterns from your data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each job consumes tokens from a configurable budget (default: 5,000 tokens per execution). When the budget runs out, lower-priority jobs get skipped. This means enriching connections (the most impactful job) always runs, while preference learning (the least time-sensitive) gets skipped first if resources are tight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trigger: idle detection
&lt;/h2&gt;

&lt;p&gt;The engine only runs when you're not using the system. If you're actively writing notes or reading emails, the background jobs wait. This matters for two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource contention.&lt;/strong&gt; The LLM (whether local via Ollama or remote via API) is a shared resource. Background jobs competing with user requests for model access would add latency to the interactions you actually care about.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Relevance.&lt;/strong&gt; Sleep-time processing works on data that has settled. Running entity extraction on a note you're still editing wastes tokens — the note will change again in 30 seconds. Waiting until you're idle means processing stable data.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The idle detector is simple: if no user activity (API requests from the dashboard, agent messages, WebSocket heartbeats) has occurred in the last N minutes, the user is idle. The scheduler checks this condition before executing each run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 1: Enrich connections
&lt;/h2&gt;

&lt;p&gt;The highest-priority job. It finds records that are "hot" (recently accessed, heat score &amp;gt; 0.3) but poorly connected in the knowledge graph (fewer than 3 entity links). These are records you care about but that the system doesn't fully understand yet.&lt;/p&gt;

&lt;p&gt;Here's what the query returned on a real run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Optimal Fuse Burn Rate Calculations v3    heat: 0.5  links: 2
Recipe: Rodney's Smoked Eyebrows Marinade heat: 0.5  links: 2
Banned Substances List (and Why Rodney…)  heat: 0.5  links: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three notes flagged. Each has a warm heat score (recently accessed) but only 2 entity links where the average for this user is 5+. The initial extraction caught the obvious entities, but a second pass might find connections to people, projects, or locations that were mentioned implicitly.&lt;/p&gt;

&lt;p&gt;The engine re-enqueues them to the async extraction pipeline at &lt;code&gt;priority: low&lt;/code&gt; — they won't compete with real-time user actions. When the extraction worker picks them up, it sends the full note content to the LLM for a more thorough entity pass than the initial CRUD hook provides.&lt;/p&gt;

&lt;p&gt;Why prioritize this job? Because the knowledge graph is the foundation of search ranking, the digest engine, and the agent's contextual awareness. A poorly connected hot record means the system is blind to something you're actively working on. Enriching it improves everything downstream.&lt;/p&gt;

&lt;p&gt;Cost on this run: &lt;strong&gt;600 tokens&lt;/strong&gt; (3 records × ~200 tokens each). Execution time: &lt;strong&gt;16ms&lt;/strong&gt; (just the enqueue — the actual extraction happens later).&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 2: Generate summary
&lt;/h2&gt;

&lt;p&gt;Aggregates a week of change log activity and the most active entities from the knowledge graph into a single pre-computed insight.&lt;/p&gt;

&lt;p&gt;On this run, the change log query returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;201 inserts, 5 updates, 152 deletes&lt;/span&gt;
&lt;span class="na"&gt;rss&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;          &lt;span class="s"&gt;141 inserts&lt;/span&gt;
&lt;span class="na"&gt;emails&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;61 inserts, 32 updates, 7 deletes&lt;/span&gt;
&lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;2 updates&lt;/span&gt;
&lt;span class="na"&gt;kanban_cards&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1 update&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the top graph entities by recent activity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Madrid              (location)      28 mentions
micelclaw           (organization)  12 mentions
Meta Platforms, Inc.(organization)   7 mentions
Instagram           (location)       6 mentions
Victoria            (person)         1 mention
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things jump out. First: 201 file inserts and 152 file deletes in one week — that's a bulk operation or a sync cycle, not manual activity. The summary captures this so the agent can mention it if asked "what happened this week?" without scanning 600+ change log rows at query time.&lt;/p&gt;

&lt;p&gt;Second: "Instagram" classified as a location is an entity extraction error — the kind of noise the enrichment job (Job 1) and the merge candidates system are designed to catch over time.&lt;/p&gt;

&lt;p&gt;The summary gets stored as a &lt;code&gt;weekly_summary&lt;/code&gt; insight with a 7-day TTL. No LLM call needed — it's pure SQL aggregation.&lt;/p&gt;

&lt;p&gt;Cost on this run: &lt;strong&gt;50 tokens&lt;/strong&gt; (fixed). Execution time: &lt;strong&gt;14ms&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 3: Detect patterns
&lt;/h2&gt;

&lt;p&gt;The most interesting job. It self-joins &lt;code&gt;entity_links&lt;/code&gt; to find pairs of entities that co-occur in 3 or more records but have no direct link between them — latent connections nobody made explicit.&lt;/p&gt;

&lt;p&gt;On this run, five patterns emerged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rodney ↔ Dolores     co-occur in 12 records, no direct link
Rodney ↔ Linda       co-occur in 11 records, no direct link
Rodney ↔ BoomClaw    co-occur in 10 records, no direct link
Warehouse B ↔ Rodney co-occur in 10 records, no direct link
Benny ↔ Rodney       co-occur in 10 records, no direct link
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every pattern radiates from "Rodney" — he's a hub entity that appears alongside four other entities across 10-12 records without any direct graph edge connecting them. The extraction pipeline created links from each note/email to "Rodney" and to "Dolores" independently, but never linked Rodney to Dolores directly. The co-occurrence pattern reveals the relationship that was hiding in plain sight.&lt;/p&gt;

&lt;p&gt;Each pair becomes a &lt;code&gt;connection_discovered&lt;/code&gt; insight with a 14-day TTL. The next time you search for "Rodney," the graph traversal finds Dolores, Linda, BoomClaw, Warehouse B, and Benny — even though no single record ever says "Rodney works with Dolores."&lt;/p&gt;

&lt;p&gt;This is the job that produces the "how did it know that?" moments. The answer is always the same: it counted co-occurrences while you weren't looking.&lt;/p&gt;

&lt;p&gt;The query itself — a self-join on &lt;code&gt;entity_links&lt;/code&gt; filtered by &lt;code&gt;NOT EXISTS&lt;/code&gt; — took &lt;strong&gt;138ms&lt;/strong&gt;. That's the heaviest operation in the pipeline, and it runs during idle time where nobody notices. At query time, the connections are already in the graph.&lt;/p&gt;

&lt;p&gt;Cost on this run: &lt;strong&gt;30 tokens&lt;/strong&gt; (fixed — pure SQL, no LLM).&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 4: Update preferences
&lt;/h2&gt;

&lt;p&gt;The system learns behavioral patterns by analyzing your data over time. On this run, two patterns were detected:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing time distribution (last 30 days):&lt;/strong&gt; All 50 notes created at hour 12 UTC. That's not a preference — that's a signal so strong it maxed out confidence immediately. The system now knows that if you're going to write a note, it's probably at noon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tag frequency:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;safety&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;11, personal&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;9, r-and-d&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6, strategy&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5, humor&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both get persisted via UPSERT with incremental confidence — each observation nudges the score up by 0.05, capped at 0.95. After multiple runs, the preferences look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;scheduling / preferred_writing_hour = "12"&lt;/span&gt;
  &lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.95 (max), evidence&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20,650 observations&lt;/span&gt;

&lt;span class="s"&gt;organization / preferred_tags = ["safety","personal","r-and-d","strategy","humor"]&lt;/span&gt;
  &lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.95 (max), evidence&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;417 observations&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent uses these when it needs to make decisions. Scheduling a reminder? It knows noon is when you're active. Suggesting tags for a new note? It offers your most-used tags first. Creating a diary entry template? It matches your writing style.&lt;/p&gt;

&lt;p&gt;If a preference is wrong, you delete it via the API. The system may re-learn it later if the pattern persists, but with reduced confidence — the deletion counts as negative feedback.&lt;/p&gt;

&lt;p&gt;Cost on this run: &lt;strong&gt;20 tokens&lt;/strong&gt; (fixed — pure SQL, no LLM).&lt;/p&gt;

&lt;h2&gt;
  
  
  The real numbers: 700 tokens, 184 milliseconds
&lt;/h2&gt;

&lt;p&gt;Here's the actual pipeline summary from the run above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────┬────────┬────────┬───────────────────────────────┐
│ Job                │ Tokens │ ms     │ Output                        │
├────────────────────┼────────┼────────┼───────────────────────────────┤
│ enrich_connections │ 600    │ 16     │ 3 notes re-enqueued           │
│ generate_summary   │ 50     │ 14     │ 1 weekly_summary insight      │
│ detect_patterns    │ 30     │ 138    │ 5 connection_discovered       │
│ update_preferences │ 20     │ 16     │ 2 preferences updated         │
├────────────────────┼────────┼────────┼───────────────────────────────┤
│ Total              │ 700    │ 184    │                               │
└────────────────────┴────────┴────────┴───────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;700 out of the 5,000 token budget — 14%. The full pipeline completed in under 200 milliseconds. Three of the four jobs are pure SQL with zero LLM calls. Only &lt;code&gt;enrich_connections&lt;/code&gt; queues work for the model, and even that just enqueues — the actual extraction runs later at low priority.&lt;/p&gt;

&lt;p&gt;Every execution gets logged to &lt;code&gt;sleep_time_jobs&lt;/code&gt; for auditability. If a job fails, the error is recorded and the next job still runs — the pipeline is fault-tolerant by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token budget: sleep-time vs test-time
&lt;/h2&gt;

&lt;p&gt;Every sleep-time execution has a capped budget — 5,000 tokens by default, configurable per user. This prevents runaway costs from background processing. The jobs run in priority order and stop when the budget is exhausted.&lt;/p&gt;

&lt;p&gt;The insight from Letta's research holds: spending tokens during idle time dramatically reduces what you need to spend during active conversations. When the agent already knows that Rodney is connected to Dolores across 12 records (because Job 3 discovered it overnight), answering "who works with Rodney?" costs a graph traversal query (~5ms) instead of a full cross-domain LLM analysis (~3,000 tokens).&lt;/p&gt;

&lt;p&gt;We track sleep-time and test-time token usage separately in the token metrics dashboard, so you can see the trade-off directly: more sleep-time tokens → fewer test-time tokens → faster, cheaper responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-stage digest
&lt;/h2&gt;

&lt;p&gt;The sleep-time engine powers an evolved version of the Digest Engine — the system that tells the agent "here's what changed since you last checked."&lt;/p&gt;

&lt;p&gt;The original digest was simple: scan the change log, format a markdown file, write it to the agent's workspace. The agent reads it on the next heartbeat.&lt;/p&gt;

&lt;p&gt;The v2 digest is a three-stage pipeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 1 — Selection.&lt;/strong&gt; Filter changes by relevance using configurable rules stored in a &lt;code&gt;digest_rules&lt;/code&gt; table. VIP emails (from your boss, from specific contacts) trigger immediate notification via PostgreSQL LISTEN/NOTIFY. Routine changes (a synced contact updated its phone number) get buffered for the periodic digest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 2 — Correlation.&lt;/strong&gt; Use the LLM to discover cross-domain connections between changes. "You received an email from Ana García. Ana is attending tomorrow's meeting. You have 2 unfinished notes about the project she's working on." This stage is why sleep-time matters — the correlation discovery happens in the background, not when the agent is trying to respond to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 3 — Scoring.&lt;/strong&gt; Rate each item by urgency, cross-domain relevance, and historical feedback (did the user act on similar insights before?). The output shifts from "what changed" to "what matters and why."&lt;/p&gt;

&lt;p&gt;The scored digest gets written to DIGEST.md in the agent's workspace. The agent reads it and decides what to surface. Urgent items might trigger an immediate notification. Low-score items accumulate for a daily summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The world model
&lt;/h2&gt;

&lt;p&gt;One output of the sleep-time engine is a materialized "world model" — a living document that summarizes your current state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Active projects and their status&lt;/li&gt;
&lt;li&gt;Key people and recent interactions&lt;/li&gt;
&lt;li&gt;Upcoming deadlines and events&lt;/li&gt;
&lt;li&gt;Behavioral patterns and preferences&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The world model is updated incrementally. Each sleep-time run only modifies the sections affected by recent changes. The agent references it as persistent context — a pre-computed summary of "what's going on in your life right now" that doesn't need to be recomputed every conversation.&lt;/p&gt;

&lt;p&gt;This is inspired by Daniel Miessler's PAI framework (MISSION.md, GOALS.md, PROJECTS.md pattern), adapted to structured data. Instead of the user maintaining these documents manually, the system generates them from real data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero cost when idle
&lt;/h2&gt;

&lt;p&gt;The most important design decision: when nothing has changed, the engine does nothing. Zero tokens. Zero queries. The scheduler checks for pending changes in the change log before executing any job. No changes → skip the entire run.&lt;/p&gt;

&lt;p&gt;This means the system's cost is proportional to your activity, not to time. A weekend where you don't use the system costs nothing. A busy Monday with 50 emails and 10 notes triggers multiple enrichment passes. The cost follows the value.&lt;/p&gt;

&lt;p&gt;Similarly, the digest delivery to the agent is conditional. No changes → no DIGEST.md written → no system event → the agent doesn't wake up → zero tokens consumed. This was a deliberate choice over a heartbeat model where the agent would check for updates periodically regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I'd add entity type validation in the summary job.&lt;/strong&gt; The real run showed "Instagram" classified as a &lt;code&gt;location&lt;/code&gt; — a clear extraction error that propagated into the weekly summary. A simple validation step (is this entity type plausible for this name?) would catch obvious misclassifications before they pollute insights. The data exists to fix this; we just haven't built the filter yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd build the pattern detection job first, not the enrichment job.&lt;/strong&gt; Enrichment (Job 1) improves the knowledge graph incrementally. Pattern detection (Job 3) produces visible, surprising insights that users actually react to. "Ana García is connected to Project Tempest" is a moment of delight. "We added 2 more entity links to your note about Tempest" is invisible maintenance. Leading with delight would have made the sleep-time engine feel valuable sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd make the token budget adaptive.&lt;/strong&gt; Right now it's a flat 5,000 tokens per run. A smarter approach: scale the budget with the amount of pending work. 3 new records → 1,000 tokens. 50 new records after a sync → 10,000 tokens. The budget should match the opportunity, not be a fixed ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd add a "sleep-time log" visible in the dashboard.&lt;/strong&gt; Currently, the only way to see what the engine did is through the insights API and the sleep_time_jobs table. A visible log ("Last night I discovered 3 new connections, updated 2 preferences, and generated your weekly summary") would build trust and make the background processing feel tangible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A personal AI system that only works when you talk to it is wasting 95% of its available compute. The data is sitting in PostgreSQL. The model is loaded in Ollama. The knowledge graph has gaps that a 30-token LLM call could fill. Why wait for the user to ask?&lt;/p&gt;

&lt;p&gt;Sleep-time compute shifts the work from "the user asked a question and now we scramble" to "we already know the answer because we connected the dots overnight." Four jobs, a token budget, an idle detector, and a three-stage digest pipeline. The system gets smarter while you sleep.&lt;/p&gt;

&lt;p&gt;The insight that makes it all work: spending tokens when nobody is waiting for a response is categorically cheaper — in latency, in user experience, and in total cost — than spending them when someone is staring at a loading spinner.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: PII-aware routing — how we send sensitive data to local models and everything else to the cloud, without the user having to think about it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>postgres</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Hybrid search with RRF: combining pgvector, tsvector, and a knowledge graph in one query"</title>
      <dc:creator>Victor García</dc:creator>
      <pubDate>Tue, 24 Mar 2026 12:04:03 +0000</pubDate>
      <link>https://dev.to/micelclaw/hybrid-search-with-rrf-combining-pgvector-tsvector-and-a-knowledge-graph-in-one-query-1d80</link>
      <guid>https://dev.to/micelclaw/hybrid-search-with-rrf-combining-pgvector-tsvector-and-a-knowledge-graph-in-one-query-1d80</guid>
      <description>&lt;p&gt;Here's a search query: "beach trip."&lt;/p&gt;

&lt;p&gt;Full-text search finds nothing — no record contains the word "beach." But there's a note that says "Qué calor en Valencia, el agua estaba perfecta." Semantic search finds it because the embedding for "beach trip" is close to the embedding for a hot day at the beach in Valencia.&lt;/p&gt;

&lt;p&gt;Now a different query: "Ana García."&lt;/p&gt;

&lt;p&gt;Semantic search returns a dozen vaguely related records. Full-text search returns the 3 records that literally contain "Ana García." But neither shows you that Ana attended last week's meeting, is CC'd on 5 email threads, and appears in tomorrow's calendar — connections that only the knowledge graph knows about.&lt;/p&gt;

&lt;p&gt;No single search method is enough. We needed all three, plus a way to combine them that doesn't require manual tuning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four signals
&lt;/h2&gt;

&lt;p&gt;Our search pipeline produces four independent scores for every candidate result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Semantic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pgvector cosine similarity&lt;/td&gt;
&lt;td&gt;Meaning-based matches ("beach" → "calor en Valencia")&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Full-text&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;tsvector + GIN + ts_rank&lt;/td&gt;
&lt;td&gt;Exact keyword matches, fast and precise&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Graph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;entity_links overlap&lt;/td&gt;
&lt;td&gt;Relational connections ("Ana García" → meetings she attended)&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Heat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;record_heat table&lt;/td&gt;
&lt;td&gt;Temporal relevance (recently accessed records)&lt;/td&gt;
&lt;td&gt;Free (display), Pro (in ranking)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Free tier users get full-text search only — which is still fast and well-ranked thanks to tsvector with weighted columns (title gets weight A, content gets weight B, tags get weight C). Pro users get all four signals fused together.&lt;/p&gt;

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

&lt;p&gt;The search happens in seven steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query: "Ana García project update"
    │
    ├── 1. Vector search ──→ top-50 by cosine similarity
    ├── 2. Full-text search ──→ top-N by ts_rank (UNION ALL across domains)
    └── 3. Graph discovery ──→ N candidates via entity_links
                │
                ▼
         4. Deduplicate by (domain, record_id)
                │
                ▼
         5. Rank-normalize each signal to [0, 1]
                │
                ▼
         6. Detect degenerate signals
                │
                ▼
         7. Weighted fusion + multi-signal bonus
                │
                ▼
            Final ranked results with provenance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me walk through each step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Vector search
&lt;/h3&gt;

&lt;p&gt;The query text is embedded on-the-fly using the same model that embeds records (&lt;code&gt;qwen3-embedding:0.6b&lt;/code&gt;, 1024 dimensions). Then a cosine similarity query runs against the embeddings table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 0.3 minimum threshold filters garbage. The top 50 candidates move to the next step. If Ollama is down and we can't embed the query, this signal is simply skipped — the other signals still work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Full-text search
&lt;/h3&gt;

&lt;p&gt;A UNION ALL query across all domain tables, using PostgreSQL's native full-text search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'note'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;plainto_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'simple'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'event'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;plainto_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'simple'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="c1"&gt;-- ... contacts, emails, files, diary, bookmarks, kanban_cards&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We use &lt;code&gt;plainto_tsquery('simple', ...)&lt;/code&gt; instead of language-specific configurations. The &lt;code&gt;simple&lt;/code&gt; configuration doesn't stem words, which matters for multilingual data — Spanish and English records coexist, and stemming rules for one language would butcher the other.&lt;/p&gt;

&lt;p&gt;Each domain table has a &lt;code&gt;search_vector tsvector&lt;/code&gt; column maintained by a trigger (or &lt;code&gt;GENERATED ALWAYS AS ... STORED&lt;/code&gt; for newer tables). The vectors are weighted: title gets &lt;code&gt;'A'&lt;/code&gt;, description/content gets &lt;code&gt;'B'&lt;/code&gt;, tags get &lt;code&gt;'C'&lt;/code&gt;. A match in the title ranks higher than a match in the body.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Graph discovery
&lt;/h3&gt;

&lt;p&gt;This signal is different — it doesn't match text, it matches relationships.&lt;/p&gt;

&lt;p&gt;The query is matched against &lt;code&gt;graph_entities.normalized_name&lt;/code&gt;. If "Ana García" matches a Person entity, we find all records linked to that entity via &lt;code&gt;entity_links&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Find entities mentioned in the query&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;graph_entities&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;normalized_name&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%ana garcia%'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Find all records linked to those entities&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;source_type&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entity_links&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;target_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'graph_entity'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;entity_ids&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;graph_score&lt;/code&gt; for each result is the overlap ratio: how many of the query's entities appear in the result's connections, divided by the total entities found in the query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Deduplication
&lt;/h3&gt;

&lt;p&gt;The three signals produce candidate sets that overlap. A note containing "Ana García" might appear in vector search (semantically similar), full-text search (exact keyword match), and graph search (linked to the Ana García entity). We deduplicate by &lt;code&gt;(domain, record_id)&lt;/code&gt; and track which signals produced each candidate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Rank-based normalization
&lt;/h3&gt;

&lt;p&gt;Here's where it gets interesting. We do NOT normalize by raw scores. We normalize by rank position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;normalized_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top result in a signal gets 1.0. The bottom gets nearly 0. A candidate absent from a signal gets 0.&lt;/p&gt;

&lt;p&gt;Why rank-based instead of min-max normalization? Because cosine similarity scores cluster. In a typical query, the top-50 vector search results might have similarities between 0.54 and 0.64 — a 10-point range. Min-max normalization would stretch this to 0.0–1.0, making the difference between rank 1 and rank 50 look huge when it's actually tiny.&lt;/p&gt;

&lt;p&gt;Rank-based normalization treats position as the signal, not magnitude. First place is first place, whether it scored 0.99 or 0.55.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Degenerate signal detection
&lt;/h3&gt;

&lt;p&gt;Sometimes a signal doesn't discriminate. If all 50 vector search results have cosine similarities within 5% of each other, the signal is noise — everything "looks the same" to the embedding model.&lt;/p&gt;

&lt;p&gt;When this happens, the weight assigned to the degenerate signal gets redistributed proportionally to the other signals. The search doesn't fail — it just relies more on the signals that are actually informative.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Weighted fusion + multi-signal bonus
&lt;/h3&gt;

&lt;p&gt;The final score combines all four signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;α&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;heat&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;β&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;semantic&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;γ&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;fulltext&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;δ&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;α&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;β&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;γ&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;δ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default weights: all equal at 0.25 each. But the user can adjust them via the dashboard's ranking sliders — crank heat to prioritize recent activity, drop graph to ignore relationship signals, etc.&lt;/p&gt;

&lt;p&gt;Then comes the multi-signal bonus:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signals that found this result&lt;/th&gt;
&lt;th&gt;Multiplier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 signal only&lt;/td&gt;
&lt;td&gt;×1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 signals&lt;/td&gt;
&lt;td&gt;×1.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 signals&lt;/td&gt;
&lt;td&gt;×1.50&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A result that appears in vector search, full-text search, AND graph search gets a 50% bonus. This rewards results that are independently confirmed by multiple methods — they're almost certainly relevant.&lt;/p&gt;

&lt;p&gt;Heat doesn't count for the bonus calculation. It's a temporal signal, not a relevance signal — a record being hot doesn't mean it matches the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two search modes
&lt;/h2&gt;

&lt;p&gt;The system exposes two search endpoints that use this pipeline differently:&lt;/p&gt;

&lt;h3&gt;
  
  
  Standard search: &lt;code&gt;GET /search&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The default. Uses Reciprocal Rank Fusion with fixed weights:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rrf_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Σ&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank_i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="n"&gt;where&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rrf_score&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;heat_score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RRF is elegant: it ignores raw scores entirely, only caring about rank position. A result that's #1 in vector search and #3 in full-text search gets &lt;code&gt;1/(60+1) + 1/(60+3) = 0.0164 + 0.0159 = 0.0323&lt;/code&gt;. The K=60 constant smooths the curve — the difference between rank 1 and rank 10 is meaningful but not extreme.&lt;/p&gt;

&lt;p&gt;Heat acts as a post-fusion tiebreaker with a maximum 10% boost. It never dominates relevance. A cold but highly relevant result always beats a hot but marginally relevant one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Advanced search: &lt;code&gt;GET /search/advanced&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0a59tag4s2lr5xnghxhb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0a59tag4s2lr5xnghxhb.png" alt=" " width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full power mode. Four independent sliders, degenerate signal detection, multi-signal bonus. Used by the Search module in the dashboard where users can see and control exactly how results are ranked.&lt;/p&gt;

&lt;p&gt;Every result includes full provenance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provenance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"heat_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.52&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"vector_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"fulltext_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.52&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"graph_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.11&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Complete transparency. The user can see that a result ranked high because of semantic similarity (0.68) despite low graph connectivity (0.11), and adjust weights accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free tier: surprisingly good without vectors
&lt;/h2&gt;

&lt;p&gt;Free users don't get semantic search, graph search, or heat-weighted ranking. They get tsvector + GIN full-text search across all domains, with &lt;code&gt;ts_rank()&lt;/code&gt; scoring and weighted columns.&lt;/p&gt;

&lt;p&gt;This sounds like a big downgrade. In practice, it's surprisingly solid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Searches for names, titles, and specific terms work perfectly — full-text search is exact.&lt;/li&gt;
&lt;li&gt;Weighted tsvector means a match in the title ranks above a match in the body.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;plainto_tsquery('simple', ...)&lt;/code&gt; handles both Spanish and English without configuration.&lt;/li&gt;
&lt;li&gt;The UNION ALL across domains means one search bar finds notes, emails, events, contacts, and files.&lt;/li&gt;
&lt;li&gt;GIN indexes make it O(log n) — fast even with tens of thousands of records.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The meaningful Pro differentiator is semantic search: finding "calor en Valencia" when you search for "beach trip." That's genuinely impossible with keyword matching. But for the 80% of searches where people type exactly what they're looking for, free tier search works fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Temporal expansion: the "wow" moment
&lt;/h2&gt;

&lt;p&gt;Here's a feature that surprised us with how useful it turned out to be.&lt;/p&gt;

&lt;p&gt;If the query mentions an entity that has upcoming events (within ±7 days), temporally close results get a boost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;temporal_boost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;proximity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice: you search for "Ana García." The system finds that Ana has a meeting with you tomorrow. Notes, emails, and contacts related to Ana that were created or accessed in the last week get boosted. The search results naturally organize around "here's everything relevant to Ana before your meeting tomorrow."&lt;/p&gt;

&lt;p&gt;We didn't plan this as a feature — it fell out of having the knowledge graph and the calendar in the same database. But it consistently produces the kind of results that make people say "how did it know I needed that?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The tuning knobs
&lt;/h2&gt;

&lt;p&gt;Five parameters control the pipeline behavior:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MIN_SIMILARITY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;Cosine sim threshold. Higher = less vector noise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RRF_FETCH_SIZE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;Candidates per signal. Lower = faster, fewer candidates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RRF_K&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;RRF smoothing constant. Higher = smoother rank differences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-signal factor&lt;/td&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;td&gt;Bonus per additional signal (1 + 0.25 × (count-1))&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Degenerate threshold&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;Signal suppression when score range is too narrow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We've left these at their defaults since implementation. The multi-signal bonus and degenerate detection handle most edge cases automatically. If we ever need to tune, the provenance metadata on every result tells us exactly which signal is helping or hurting.&lt;/p&gt;

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

&lt;p&gt;All of this runs inside a single PostgreSQL instance, on the same machine that serves the API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vector search (50 candidates)&lt;/td&gt;
&lt;td&gt;~15-30ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-text search (UNION ALL, 7 tables)&lt;/td&gt;
&lt;td&gt;~5-15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Graph discovery&lt;/td&gt;
&lt;td&gt;~5-10ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fusion + scoring&lt;/td&gt;
&lt;td&gt;~2-5ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total (standard search)&lt;/td&gt;
&lt;td&gt;~30-60ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total (advanced search, 4 signals)&lt;/td&gt;
&lt;td&gt;~50-80ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No Elasticsearch. No Solr. No separate search service. pgvector and tsvector are PostgreSQL extensions that run in the same process as the rest of the database. One backup strategy, one connection pool, one operational concern.&lt;/p&gt;

&lt;p&gt;For a personal system with a few thousand records per domain, this is more than fast enough. If we ever hit scale problems (unlikely for single-user), the first optimization would be reducing &lt;code&gt;RRF_FETCH_SIZE&lt;/code&gt; from 50 to 20 — cutting candidate generation in half with minimal quality loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I'd implement full-text search from the very beginning, not as a free-tier afterthought.&lt;/strong&gt; We built vector search first (Phase 3) and added tsvector later as a "fallback for free users." Turns out full-text search is essential even for Pro users — it catches exact matches that embeddings miss. "Show me the email from &lt;a href="mailto:patricia@work.com"&gt;patricia@work.com&lt;/a&gt;" is a full-text query, not a semantic one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd add provenance to the standard search too, not just advanced.&lt;/strong&gt; We initially only exposed the score breakdown in &lt;code&gt;/search/advanced&lt;/code&gt;. When we added it to the standard &lt;code&gt;/search&lt;/code&gt; endpoint (in the provenance field), debugging search quality became ten times easier. Every bug report went from "the search is bad" to "this result has vector_score 0.8 but graph_score 0, why?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd explore reranking with a cross-encoder.&lt;/strong&gt; Our pipeline does retrieval + fusion but no reranking. A small cross-encoder model (like ms-marco-MiniLM) could re-score the top 20 results for higher precision. We deferred this because the current quality is good enough and adding another model to the Ollama queue would increase latency. But for a future post-MVP iteration, it's the obvious next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The trick to hybrid search isn't the individual signals — pgvector and tsvector are well-documented, and knowledge graph traversal is just recursive CTEs. The trick is the fusion: how you combine signals with different scales, different failure modes, and different strengths.&lt;/p&gt;

&lt;p&gt;Reciprocal Rank Fusion solves the scale problem — ranks instead of raw scores. Degenerate signal detection solves the failure mode problem — a noisy signal gets suppressed instead of poisoning results. Multi-signal bonus solves the confidence problem — results confirmed by multiple methods are almost certainly good.&lt;/p&gt;

&lt;p&gt;Four signals, one UNION ALL, one PostgreSQL instance, under 100ms. The search that finds "calor en Valencia" when you type "beach trip" — and also shows you that Ana García has a meeting with you tomorrow.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next up: sleep-time compute for personal data — what your AI should be doing while you sleep, and why idle cycles are the most valuable compute you have.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>search</category>
      <category>pgvector</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
