<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Hack Your Way: OpenClaw]]></title><description><![CDATA[Building a personal AI assistant from scratch on OpenClaw — memory architecture, automation, infrastructure decisions, and everything that breaks along the way.]]></description><link>https://hackyourway.substack.com/s/openclaw</link><image><url>https://substackcdn.com/image/fetch/$s_!fyVV!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71eb1bf2-e4c7-45f1-8ca1-dbc743f087b7_636x636.png</url><title>Hack Your Way: OpenClaw</title><link>https://hackyourway.substack.com/s/openclaw</link></image><generator>Substack</generator><lastBuildDate>Sun, 09 Aug 2026 13:00:05 GMT</lastBuildDate><atom:link href="https://hackyourway.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Paul Brennaman]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[hackyourway@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[hackyourway@substack.com]]></itunes:email><itunes:name><![CDATA[Paul Brennaman]]></itunes:name></itunes:owner><itunes:author><![CDATA[Paul Brennaman]]></itunes:author><googleplay:owner><![CDATA[hackyourway@substack.com]]></googleplay:owner><googleplay:email><![CDATA[hackyourway@substack.com]]></googleplay:email><googleplay:author><![CDATA[Paul Brennaman]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Giving Your OpenClaw Agent Access to Google Workspace Is Harder Than It Should Be]]></title><description><![CDATA[I wanted Hank to read my calendar.]]></description><link>https://hackyourway.substack.com/p/google-oauth-agent-access</link><guid isPermaLink="false">https://hackyourway.substack.com/p/google-oauth-agent-access</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/e5d71982-e0f2-478a-9e14-1cc139e94fff_1400x1000.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I wanted Hank to read my calendar. Simple enough request &#8212; my OpenClaw AI assistant runs on a remote server, and I wanted it to pull today's events as part of the morning brief. I didn't realize that "read my Google calendar" would require creating a Google Cloud project, configuring an OAuth consent screen, navigating a multi-step remote auth flow, and then debugging a token expiry issue that took some digging to understand.</p><p>If you've been through this, you know. If you haven't, this is your preview.</p><h2>Why All This Machinery Exists</h2><p>Before walking through what happened, it helps to understand why Google's OAuth setup is the way it is.</p><p>When any application &#8212; including an AI agent &#8212; wants to access your Gmail, Google Drive, or Google Calendar, it needs to prove two things: who it is, and that you've consented to give it access. Google handles both through OAuth 2.0, and the infrastructure for that lives in Google Cloud Platform.</p><p>Here's how the pieces fit together:</p><p><strong>GCP Project</strong> &#8212; The container for everything. Your OAuth app lives inside a project. If you don't have one, you need to create one before you can do anything else.</p><p><strong>OAuth Consent Screen</strong> &#8212; The configuration that defines your 'app' &#8212; its name, the scopes it requests, and whether it's in Testing or Production mode. This is what you see when Google asks you to grant access.</p><p><strong>OAuth Client</strong> &#8212; The actual credentials &#8212; a client ID and client secret &#8212; that your application uses to identify itself when requesting tokens. There can be multiple clients under one project.</p><p>None of this is Hank-specific or OpenClaw-specific. This is just how Google works for any third-party application that wants to access user data. The friction is real, and it's intentional &#8212; Google is protecting people from apps that request broad access without accountability.</p><p>For a personal AI assistant that only you use, it's a lot of ceremony for what feels like a simple thing. But it's a one-time setup, and once it's done it stays done &#8212; mostly.</p><h2>The <code>gog</code> CLI and the Auth Flow</h2><p>Hank uses a CLI tool called <a href="https://gogcli.sh">gog</a> to interact with Google Workspace &#8212; Gmail, Calendar, Drive, Contacts, Sheets, and Docs. Once it's configured, it's straightforward: <code>gog calendar events</code> returns events, <code>gog gmail send</code> sends email, and so on.</p><p>The initial setup requires running <code>gog auth add</code> with your Google account email and the services you want to authorize. The experience here depends on where your agent is running.</p><p><strong>If your agent runs locally on your machine,</strong> <code>gog auth add</code> opens a browser window automatically. The whole flow is seamless &#8212; sign in, approve, done.</p><p><strong>If your agent runs on a remote server</strong> (which is my setup &#8212; Hank lives on a GCP VM), you need the <code>--remote</code> flag. This is where it gets interesting, and also where the agent can actually participate in the process.</p><p>When I ran into this, I didn't SSH into the server and run commands myself. I asked Hank to do it. The agent ran:</p><pre><code>gog auth add agent-workspace@gmail.com --remote --step 1 \
  --services gmail,calendar,drive,contacts,docs,sheets
</code></pre><p>Step 1 generates an authorization URL and prints it &#8212; Hank sent it to me via Telegram. I opened the URL in my local browser, signed in, approved the permissions, and Google redirected me to a callback URL like <code>http://127.0.0.1:&lt;port&gt;/oauth2/callback?code=...</code>. The page fails to load because nothing is listening on that port locally. That's expected. I copied the full URL from the address bar and sent it back to Hank, who then ran step 2:</p><pre><code>gog auth add agent-workspace@gmail.com --remote --step 2 \
  --auth-url 'http://127.0.0.1:&lt;port&gt;/oauth2/callback?code=...'
</code></pre><p>Hank extracted the authorization code from the URL, <code>gog</code> exchanged it with Google for an access token and a refresh token, and stored the refresh token on the server. The whole thing happened in the chat &#8212; no SSH session, no terminal on my end.</p><p>This is one of the better examples of what it actually feels like to work <em>with</em> an agent rather than just at it. The agent does the technical lifting; you handle the one thing only you can do (authenticate as yourself in a browser).</p><h2>The 7-Day Expiry Nobody Tells You About</h2><p>A few days after getting everything working, Hank's Google Workspace access stopped working entirely. The error was:</p><pre><code>oauth2: "invalid_grant" "Token has been expired or revoked."
</code></pre><p><code>invalid_grant</code> is vague. It could mean you revoked access, the token hasn't been used in six months, you changed your password, or a few other things. It doesn't tell you which one.</p><p>After some digging, the answer was in Google's OAuth <a href="https://developers.google.com/google-ads/api/docs/get-started/common-errors#:~:text=A%20Google%20Cloud%20Platform%20project,token%20expiring%20in%207%20days.&amp;text=Your%20Google%20project's%20publishing%20status,and%20receives%20an%20invalid_grant%20error.">documentation</a> &#8212; buried in some common errors doc:</p><blockquote><p><em>A Google Cloud Platform project with an OAuth consent screen configured for an external user type and a publishing status of 'Testing' is issued a refresh token expiring in 7 days.</em></p></blockquote><p>There it is. When your OAuth consent screen is in <strong>Testing</strong> mode, Google intentionally expires refresh tokens after 7 days. The intent is to prevent developers from leaving half-built apps with long-lived tokens floating around. It's a reasonable policy. It's also not well-advertised, and <code>invalid_grant</code> gives you no indication that this is what happened.</p><p>Here's the thing that trips people up: if you're a developer, you probably already know that access tokens are short-lived &#8212; they expire in about an hour and get refreshed automatically. Refresh tokens are supposed to be the long-lived part. That's true in Production. In Testing, the refresh token itself expires in a week whether you do anything or not.</p><p>If you're not a developer, here's the simpler version: there are two types of tokens involved. One is like a day pass &#8212; it expires quickly and gets renewed automatically. The other is like a key &#8212; it's supposed to last a long time. In Testing mode, Google makes that key expire after 7 days as a safety measure. Once it expires, you have to go through the whole authorization flow again.</p><h2>The Fix: Publish to Production</h2><p>The solution is to move the OAuth consent screen from Testing to Production. In Google Cloud Console, this is in the <strong>Audience</strong> section of the Google Auth Platform.</p><p>Publishing to Production removes the 7-day limit. The refresh token becomes long-lived. You re-authenticate once, and you don't have to do it again unless something changes.</p><p>The natural concern when you see "Publish app" is: does this make my app public? Does Google need to review it? Is there a cost?</p><blockquote><p><strong>Does it make the app public?</strong> &#8212; It makes the consent screen technically reachable by other Google accounts, but no one can access your data without you explicitly authorizing them. In practice, it's your private app that only you use.</p></blockquote><blockquote><p><strong>Does Google need to review it?</strong> &#8212; Verification is required if you want to remove the 'unverified app' warning for other people who authorize your app. For a personal app that only you use, you can skip verification entirely &#8212; you'll see the warning when you re-auth and click through it once.</p></blockquote><blockquote><p><strong>Is there a cost?</strong> &#8212; Publishing to Production is free. Formal verification itself is also free for most scope types &#8212; though apps requesting certain restricted scopes may require a third-party security assessment that has a cost. For a personal single-user app where you skip verification, none of this applies.</p></blockquote><p>There's one additional thing worth knowing: Google can reclassify scope risk ratings over time. If a scope you're using gets upgraded to a more restricted tier, you'd receive email notifications and a grace period. Your app continues to work in the meantime. It's worth being aware of, not worth worrying about.</p><h2>What the Unverified App Warning Actually Looks Like</h2><p>When you re-authenticate after publishing to Production, you'll see a Google warning screen: "Google hasn't verified this app." It looks alarming if you're not expecting it.</p><p>This warning appears because the app requests sensitive OAuth scopes (Gmail, Calendar, Drive) and hasn't gone through Google's verification process. The warning exists to protect people from malicious third-party apps. For your own personal app, it's just noise.</p><p>To proceed: click <strong>Advanced</strong>, then <strong>"Go to [your app name] (unsafe)"</strong>. Complete the permissions grant normally. You'll only see this when you re-authenticate, not on every API call.</p><h2>The Full Fix</h2><ol><li><p>Publish the OAuth consent screen from Testing to Production in Google Cloud Console</p></li><li><p>Ask Hank to generate a fresh auth URL via <code>gog auth add --remote</code></p></li><li><p>Open the URL in the browser, complete the flow, send the callback URL back</p></li><li><p>Verify with a test email</p></li></ol><p>After that, calendar events worked again and the morning brief started including today's schedule. The root cause &#8212; Testing mode's 7-day token expiry &#8212; was something we hadn't encountered before. It's a subtle policy that produces a generic error message.</p><p>If you're setting up any application that uses Google OAuth and you expect it to run unattended for more than a week, publish to Production before you ship. You'll save yourself the debugging session.</p><div><hr></div><p><strong>Tools:</strong> gog CLI &#183; Google Cloud Platform &#183; OAuth 2.0</p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/google-oauth-agent-access">https://www.paulbrennaman.me/lab/google-oauth-agent-access</a></em></p>]]></content:encoded></item><item><title><![CDATA[When Your AI's Alarm Clock Fails, Who Watches the Watchdog?]]></title><description><![CDATA[A morning of failed cron jobs, a model fallback that didn't fire, and why the heartbeat is a better watchdog than a dedicated one.]]></description><link>https://hackyourway.substack.com/p/cron-health-watchdog</link><guid isPermaLink="false">https://hackyourway.substack.com/p/cron-health-watchdog</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/182a9058-3a4f-419a-af64-0ae5056724c5_1536x1024.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Monday morning. I noticed Hank hadn't sent a morning brief. I checked Telegram &#8212; nothing. A few minutes later I saw the error that had been delivered to a system topic: "The AI service is temporarily overloaded. Please try again in a moment." The 6 AM cron job had hit Anthropic's API at the wrong time, gotten back a 529, and died. No retry. No fallback. Just silence.</p><p>Then I checked the improvement sprint that runs at 10 AM. Same error. Same result.</p><p>Two cron jobs down in the same morning window, both for the same reason, neither of which I knew about until I went looking. That's the kind of failure that makes you want to build something.</p><h2>The First Instinct: A Watchdog Cron Job</h2><p>The obvious first move was a watchdog. A separate cron job that runs at 6:30 AM, checks if today's daily note exists, and reruns the daily-note job if it doesn't. Simple. Targeted. Done in five minutes.</p><p>We built it. It worked. And then we immediately talked ourselves out of it.</p><p>The problem with a dedicated watchdog cron job is that it only watches one thing. As soon as I add another cron job I care about &#8212; and I keep adding them &#8212; I have to remember to also add a watchdog for it. The watchdog and the watched job have to stay in sync forever. That's maintenance overhead that compounds quietly in the background until the day you forget and it bites you.</p><p>There's also a subtler issue: a watchdog cron job is a fixed solution to a dynamic problem. It checks for a specific file. If the failure mode changes &#8212; different job, different error, different recovery path &#8212; the watchdog doesn't adapt. You'd need to rewrite it.</p><h2>What About Model Fallback?</h2><p>Before scrapping the watchdog entirely, I wanted to understand why the overload error didn't trigger a fallback to a different model. OpenClaw supports model fallback via <code>agents.defaults.model.fallbacks</code> &#8212; if a provider fails, it moves to the next model in the list.</p><p>Reading the docs carefully, fallback triggers on: auth failures, rate limits (HTTP 429), and timeouts that exhausted profile rotation. The overload error is an HTTP 529 &#8212; a server-side capacity error, not a rate limit. The docs are explicit: <em>"other errors do not advance fallback."</em></p><blockquote><p><strong>HTTP 429 &#8212; Rate Limited</strong> &#8212; Your request was rejected because you've exceeded your quota. Fallback fires.</p></blockquote><blockquote><p><strong>HTTP 529 &#8212; Overloaded</strong> &#8212; The provider's infrastructure is at capacity. Classified as 'other error.' Fallback does not fire.</p></blockquote><p>This is an important distinction. Model fallback is not a general resilience mechanism &#8212; it's specifically for auth and rate limit scenarios. If you're counting on fallback to protect you against provider outages or capacity crunches, you're going to be surprised the first time one happens.</p><p>Model fallback is still worth configuring. It just doesn't solve the overload case.</p><h2>Failure Alerts: The Fast Layer</h2><p>The first concrete fix was simple: turn on failure alerts for every critical job. OpenClaw supports <code>--failure-alert</code> on cron jobs &#8212; when a job errors, it sends a Telegram message immediately.</p><pre><code>openclaw cron edit &lt;id&gt; \
  --failure-alert \
  --failure-alert-after 1 \
  --failure-alert-channel telegram \
  --failure-alert-to &lt;chat-id&gt;
</code></pre><p>This doesn't prevent failures. It just means I know within seconds instead of hours. That's valuable on its own &#8212; if I'd had failure alerts this morning, I would have seen the overload error before I'd even finished my first cup of coffee and could have manually triggered a retry.</p><p>Failure alerts are the fast layer. They're reactive, not preventive. But awareness is the prerequisite for everything else.</p><h2>The Better Answer: Heartbeat Health Check</h2><p>The real fix came from stepping back and asking a better question. I already have a heartbeat system &#8212; a periodic poll that runs every 30 minutes and checks on things. The heartbeat reads <code>HEARTBEAT.md</code> and follows instructions. It's already running. It already has judgment. It already knows how to message me.</p><p>Why add a dedicated watchdog cron at all?</p><p>Instead, we added a cron health check section to <code>HEARTBEAT.md</code>. Every heartbeat, Hank runs <code>openclaw cron list --json</code> &#8212; which returns every cron job with its last run status &#8212; and checks for failures. If a job errored and was scheduled to have run today, Hank decides what to do:</p><blockquote><p><strong>Transient error (overload, timeout, network)</strong> &#8212; Retry immediately via openclaw cron run. Then message Paul with what happened and that it's been re-triggered.</p></blockquote><blockquote><p><strong>Non-transient error (edit conflict, script failure, logic error)</strong> &#8212; Don't retry blindly. Message Paul with the error and an assessment of what went wrong. Offer to investigate.</p></blockquote><blockquote><p><strong>Already retried recently</strong> &#8212; Check heartbeat-state.json. If a retry happened less than 2 hours ago, skip &#8212; don't hammer a failing job.</p></blockquote><p>The key detail: <code>openclaw cron list --json</code> returns <em>all</em> jobs. Not a hardcoded list &#8212; everything. Every cron job I ever add is automatically covered by the health check with zero changes to <code>HEARTBEAT.md</code>. The watchdog scales with the system.</p><h2>Pros and Cons</h2><p>This approach has real advantages, but it's worth being honest about the tradeoffs.</p><p><strong>&#9989; Pro &#8212; Self-scaling</strong> &#8212; New cron jobs are covered automatically. No watchdog to create, no list to maintain.</p><p><strong>&#9989; Pro &#8212; Faster recovery</strong> &#8212; Heartbeat runs every 30 minutes. A dedicated 6:30 AM watchdog only helps once a day.</p><p><strong>&#9989; Pro &#8212; Intelligent triage</strong> &#8212; Heartbeat can distinguish transient from non-transient errors and respond differently to each.</p><p><strong>&#9989; Pro &#8212; One place to maintain</strong> &#8212; All recovery logic lives in HEARTBEAT.md, not scattered across N watchdog cron jobs.</p><blockquote><p><strong>&#9888;&#65039; Con &#8212; Same provider dependency</strong> &#8212; The heartbeat runs on the same Anthropic model that might be overloaded. If the outage is widespread, the heartbeat itself could be affected.</p></blockquote><blockquote><p><strong>&#9888;&#65039; Con &#8212; Adds complexity to the heartbeat</strong> &#8212; HEARTBEAT.md is already doing a lot &#8212; weather, calendar, WHOOP, news. Adding cron health checks makes it longer and heavier.</p></blockquote><blockquote><p><strong>&#9888;&#65039; Con &#8212; Triage can be wrong</strong> &#8212; The heartbeat classifies errors before retrying &#8212; transient gets retried, non-transient gets escalated. But automated classification isn't perfect. A logic error that surfaces as a generic failure message could get misread as transient and retried when it shouldn't be. To be fair, a dedicated watchdog cron would have the same problem &#8212; this is a limitation of automated error triage in general, not specific to the heartbeat approach.</p></blockquote><p>The single-provider dependency is the one I think about most. If Anthropic has a broad outage and both the cron job and the heartbeat are hitting the same endpoint, the heartbeat can't save what it can't reach. In practice, the failure alerts are the safety net for that scenario &#8212; I'd see the alert on my phone even if the heartbeat couldn't process it.</p><h2>What We Shipped</h2><p>By the end of the conversation, three things were in place:</p><p><code>Failure alerts</code> &#8212; All critical cron jobs now alert immediately on the first error. Fast awareness layer.</p><p><code>HEARTBEAT.md cron health check</code> &#8212; Every heartbeat checks all cron jobs, retries transients, escalates non-transients, tracks retries in heartbeat-state.json.</p><p>The watchdog cron job we initially built got deleted. The heartbeat does the job better.</p><h2>The Bigger Pattern</h2><p>What I keep finding with this system is that the instinct to build a dedicated tool for every specific problem is usually wrong. The better move is to extend the ambient infrastructure that already exists.</p><p>A watchdog cron job is specific. The heartbeat is general. General wins, because the problem space always expands.</p><p>The same logic applies to model fallback. It's tempting to treat it as a resilience catch-all &#8212; configure it and assume you're covered. But fallback is a specific mechanism for specific failure modes. Understanding exactly what it protects against (rate limits, auth failures) and what it doesn't (overload, logic errors) is what lets you build the right supplementary layers around it.</p><p>Automation is only as reliable as your understanding of its failure modes.</p><div><hr></div><p><strong>Tools:</strong> OpenClaw &#183; Telegram &#183; HEARTBEAT.md &#183; openclaw cron</p><p><strong>Previous post:</strong> <a href="/lab/morning-intelligence-brief">My AI Sends Me a Morning Brief &#8594;</a></p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/cron-health-watchdog">https://www.paulbrennaman.me/lab/cron-health-watchdog</a></em></p>]]></content:encoded></item><item><title><![CDATA[My AI Sends Me a Morning Brief. Here's What It Took to Build That.]]></title><description><![CDATA[Every morning, before I check my phone, Hank &#8212; my AI assistant running on OpenClaw &#8212; has already done a round of work.]]></description><link>https://hackyourway.substack.com/p/morning-intelligence-brief</link><guid isPermaLink="false">https://hackyourway.substack.com/p/morning-intelligence-brief</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Thu, 05 Mar 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ac0ee97f-b739-4c46-a066-ff4b85f100b0_1536x1024.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every morning, before I check my phone, Hank &#8212; my AI assistant running on OpenClaw &#8212; has already done a round of work. By the time I open Telegram, there's a message waiting: current weather, today's calendar, yesterday's WHOOP recovery score, my GCP cloud spend, a handful of AI/tech headlines, and a few content ideas for my site. All assembled, formatted, and delivered automatically. Here's how I built it.</p><h2>The Problem: Information Is Scattered</h2><p>I have a lot of data sources I care about in the morning. Weather (I'm in Atlanta &#8212; storms appear out of nowhere). Calendar. WHOOP. Cloud costs from experiments I'm running. Tech news I want to stay current on. And I've been building content for this site, so I need a constant drip of ideas.</p><p>Before this system, checking all of those things meant opening five different apps, losing five minutes, and still probably missing something. The goal was simple: one message, everything that matters, waiting for me before I start my day.</p><h2>The Architecture: HEARTBEAT.md</h2><p>The core of the system is a file called <code>HEARTBEAT.md</code> in my AI workspace. OpenClaw runs a periodic heartbeat poll on a schedule &#8212; roughly every 30 minutes &#8212; and when Hank receives it, he reads <code>HEARTBEAT.md</code> and follows whatever instructions are there.</p><p>That file is a living document. I can edit it at any time to change what Hank checks, how often, and what thresholds trigger a message versus staying silent. It's the configuration layer for my ambient intelligence system &#8212; and it's just Markdown.</p><h2>What Goes Into the Brief</h2><h3>&#127780;&#65039; Weather &#8212; Two Modes</h3><p>Weather runs in two modes. The first is a storm alert: every heartbeat, Hank checks the forecast for my ZIP code and sends a Telegram message only if severe weather is arriving in the next two hours. No sunny-day noise. Just the alert when it matters.</p><p>The second is the morning brief, which runs once per day between 7:30&#8211;9:00 AM. This one includes current conditions, the full day forecast with hourly breakdowns, high/low temps, and rain probability. I use wttr.in for the current snapshot and Open-Meteo for the structured hourly data.</p><h3>&#128197; Calendar</h3><p>Hank pulls today's events from Google Calendar via the <code>gog</code> CLI &#8212; a Google Workspace tool I have wired into my workspace. Every event gets its time, name, and location if present. If the calendar is empty, it says so. No hallucinating fake events.</p><h3>&#127947;&#65039; WHOOP Recovery Data</h3><p>I built a custom WHOOP skill for Hank earlier this month. The morning brief uses it to pull yesterday's recovery score and day strain (final values, not mid-day estimates) and today's sleep performance. If WHOOP hasn't finished processing overnight data yet, it skips gracefully instead of erroring.</p><p>What makes this useful beyond raw numbers: Hank knows my health context. I'm on a statin, which inhibits CoQ10 production. That matters specifically after heavy strength training &#8212; leg days in particular hit me with more DOMS than they otherwise would, and recovery scores in the days after tend to run suppressed as a result. A 52% recovery the morning after a hard leg session reads differently than a 52% recovery on a rest day. Hank knows the difference, and reads the numbers against my personal baseline rather than a generic one.</p><h3>&#128176; GCP Cost Summary</h3><p>I run experiments on Google Cloud, and cloud costs have a way of quietly accumulating. Every morning brief includes yesterday's spend by service and a month-to-date total, pulled directly from BigQuery billing export. If any single service spent more than $1, it gets flagged. It's a tiny budget gate that has caught runaway experiments more than once.</p><h3>&#128240; AI / Tech News</h3><p>Hank runs a few web searches &#8212; AI news, startup funding, tech headlines &#8212; and summarizes the top 3&#8211;5 stories with source links. This isn't curated or filtered through any RSS magic. It's just a model that knows what I care about, doing a quick scan and pulling signal from noise. Good enough for a morning digest; not a replacement for deep reading.</p><h3>&#128161; Content Ideas for This Site</h3><p>Every brief includes 2&#8211;3 content ideas for this site, each with a full draft outline or script. These get saved to a dated Obsidian note automatically &#8212; so even if I don't act on them today, they accumulate into a backlog I can pull from later. The brief just shows a one-line teaser and the note path.</p><h3>&#9989; Tasks</h3><p>Hank reads yesterday's and today's Obsidian daily notes and extracts anything that looks like a task &#8212; checkboxes, bullets tagged TODO, action items. They show up at the bottom of the brief as a quick "don't forget" list. Not a full task management system, just a surface-level scan that catches the obvious things.</p><h2>State Tracking: heartbeat-state.json</h2><p>The system tracks what's already been sent to avoid duplicates. A small JSON file &#8212; <code>memory/heartbeat-state.json</code> &#8212; records timestamps for the last morning brief, the last storm alert, and other one-per-day actions. Before sending anything, Hank checks this file. It's low-tech, but it works.</p><h2>Quiet Hours</h2><p>The system respects boundaries. No messages before 7 AM or after 9:30 PM on weekdays, 10 PM on weekends. If a heartbeat fires during quiet hours, Hank acknowledges it and goes back to sleep. The goal is ambient intelligence, not ambient noise.</p><h2>What I'd Add Next</h2><p>A few things on the backlog:</p><ul><li><p>Job search digest &#8212; new listings matching my criteria, pulled from saved searches</p></li><li><p>GitHub activity summary &#8212; any PRs, CI failures, or stale branches across my repos</p></li><li><p>Stock/market summary for a watchlist (simple, not financial advice)</p></li><li><p>Weekly rollup &#8212; a Friday summary of the week's briefs, wins, and open threads</p></li></ul><h2>The Bigger Picture</h2><p>What I've built here isn't particularly novel &#8212; people have been building morning dashboards and digest bots for years. What's different is the substrate. I'm not maintaining a cron script that hits a bunch of APIs. I have an assistant that understands my context &#8212; my health situation, my job search, my projects, my preferences &#8212; and applies that context to every piece of information it surfaces.</p><p>The configuration is just Markdown. Changing what the brief covers means editing a file, not touching code. That's the part that actually feels like the future.</p><div><hr></div><p><strong>Tools:</strong> OpenClaw &#183; Telegram &#183; wttr.in &#183; Open-Meteo &#183; Google Calendar &#183; WHOOP &#183; BigQuery &#183; Obsidian</p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/morning-intelligence-brief">https://www.paulbrennaman.me/lab/morning-intelligence-brief</a></em></p>]]></content:encoded></item><item><title><![CDATA[Connecting Obsidian to My AI Assistant]]></title><description><![CDATA[I've been using Obsidian for a while &#8212; daily notes, interview prep, career research, random thoughts.]]></description><link>https://hackyourway.substack.com/p/obsidian-ai-setup</link><guid isPermaLink="false">https://hackyourway.substack.com/p/obsidian-ai-setup</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Fri, 27 Feb 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/de091ed6-4243-4752-bec8-3722af6c3d97_1536x1024.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've been using Obsidian for a while &#8212; daily notes, interview prep, career research, random thoughts. But all of it was siloed on my laptop behind Obsidian Sync, invisible to Hank. Hank is my AI assistant, running on OpenClaw on a remote server &#8212; and I wanted him to be able to read my notes, create new ones, and eventually help me keep them organized. That meant getting the vault onto that server. Here's how we did it, and what we built on top of it.</p><h2>Why Not Just Use Obsidian Sync?</h2><p>Obsidian Sync is great on paper &#8212; real-time, encrypted, works across all your devices. But it's a closed system. There's no API, no way for a server-side process to access your vault. Everything lives in Obsidian's own sync infrastructure, and the only clients are the Obsidian apps themselves.</p><p>I also realized I was paying for Sync across three devices, but only ever using it on one &#8212; my laptop. I have Obsidian on my phone but basically never open it. I use Apple Notes on iOS instead. Once I understood that git could replace the cross-device sync for my actual usage pattern, canceling Sync was an easy call.</p><h2>The Stack We Used</h2><p><strong>obsidian-cli</strong> &#8212; A Go CLI by yakitrak that lets you search, create, move, and delete notes from the command line. We built it from source after auditing the code &#8212; no network calls, clean dependency list.</p><p><strong>Obsidian Git plugin</strong> &#8212; A community plugin that auto-commits and pushes your vault to a GitHub repo on a schedule. Set it to 5 minutes and forget it.</p><p><strong>Private GitHub repo</strong> &#8212; The sync layer. Free, unlimited private repos, and git history is actually better version tracking than Obsidian Sync's history.</p><h2>Auditing obsidian-cli</h2><p>Before installing anything that touches my notes, I wanted to verify it wasn't making any outbound network calls. The concern: a malicious CLI could silently exfiltrate note contents.</p><p>We cloned the repo and ran a grep across all Go source files for any HTTP, network, or URL imports:</p><pre><code>grep -rn "http\|net\.\|url\." --include="*.go" pkg/ cmd/ main.go
</code></pre><p>The only hit was <code>url.PathEscape</code> in the URI builder &#8212; used to construct <code>obsidian://</code> URIs that open the local Obsidian app. No HTTP clients, no outbound connections. The dependency list confirmed it: just a CLI framework, a fuzzy finder, some YAML/TOML parsers, and terminal UI libraries. Clean bill of health.</p><p>We also built it from source rather than using the prebuilt Homebrew binary &#8212; the compiled binary might not match the audited source if the author's release pipeline got compromised. Building from source closes that gap. Since it's a Go project with vendored dependencies, it's one command:</p><pre><code>go build -o $(which obsidian-cli) .
</code></pre><h2>Pushing the Vault to GitHub</h2><p>On my laptop, I initialized a git repo inside the vault folder and pushed it to a new private GitHub repo:</p><pre><code>git init
echo ".obsidian/workspace.json" &gt;&gt; .gitignore
git add .
git commit -m "initial vault commit"
gh repo create obsidian-vault --private --source=. --push
</code></pre><p>Then installed the Obsidian Git community plugin, set auto-commit interval to 5 minutes, and enabled pull on startup. The plugin handles everything from there &#8212; commit, push, pull, all automatic.</p><p>One thing worth noting: we added <code>.DS_Store</code> to <code>.gitignore</code> early. Mac metadata files have no business in a git repo.</p><h2>Connecting obsidian-cli on the Server</h2><p>The CLI expects Obsidian's config file (<code>obsidian.json</code>) to exist &#8212; normally written by the Obsidian desktop app to track vault locations. Since the server doesn't have Obsidian installed, we created a minimal stub to satisfy it:</p><pre><code># ~/.config/obsidian/obsidian.json
{
  "vaults": {
    "abc123": {
      "path": "/path/to/repos/obsidian-vault",
      "ts": 1709078400000,
      "open": true
    }
  }
}
</code></pre><pre><code>obsidian-cli set-default "obsidian-vault"
# Default vault set to: obsidian-vault
</code></pre><p>After that, <code>obsidian-cli list</code> returned all my notes. Hank can now search, read, and create notes from the server side.</p><h2>Reorganizing into PARA</h2><p>Before setting up any automation, I wanted to get the vault organized. Twenty-something notes scattered across a flat folder isn't a knowledge system &#8212; it's a pile.</p><p>We adopted the <strong>PARA method</strong> (Tiago Forte): four buckets that sort everything by actionability.</p><p><code>Projects/</code> &#8212; Active work with a finish line. Job search, timeshare sale.</p><p><code>Areas/</code> &#8212; Ongoing responsibilities. Home Depot history, tech setup.</p><p><code>Resources/</code> &#8212; Reference by topic. Cloud architecture, general notes.</p><p><code>Archive/</code> &#8212; Anything inactive. Old daily notes, completed projects.</p><p>Hank mapped all 20+ existing notes to the right buckets, moved them using <code>git mv</code> (which preserves history), and pushed the reorganized vault in a single commit. We also added a <code>PARA Guide.md</code> at the vault root with a plain-English decision tree for where new notes should go.</p><h2>Daily Notes Automation</h2><p>I keep a running daily note &#8212; one file per day where I jot things throughout the day. The naming convention is <code>YYYY-MM-DD.md</code>, which sorts chronologically in any file browser.</p><p>Rather than creating each day's note manually, we set up two cron jobs:</p><p><strong>6:00 AM ET &#8212; Create today's note</strong> &#8212; Creates YYYY-MM-DD.md in the Daily Notes/ folder with a blank template (Notes, Tasks, Journal sections), commits, and pushes. The note is ready before I'm awake.</p><p><strong>6:30 AM ET &#8212; Archive old notes</strong> &#8212; Keeps only the 3 most recent daily notes in the active folder. Anything older moves to Archive/Daily Notes/. Notes don't get deleted &#8212; they just graduate out of the active view.</p><p>Obsidian Git pulls the new note down to my laptop automatically on startup (or within 5 minutes), so it's just there when I open Obsidian.</p><h2>Yesterday's Notes in the Morning Briefing</h2><p>Hank already sends me a morning briefing &#8212; weather, calendar events, anything worth knowing before the day starts. We extended it to include a summary of the previous day's note.</p><p>Each morning, before sending the report, Hank reads yesterday's <code>YYYY-MM-DD.md</code>. If there's real content beyond the blank template headings, it gets summarized and appended to the report under a "&#128211; Yesterday's Notes" section. If the note was empty, that section is skipped.</p><p>If anything in the note looks like it belongs in a permanent PARA file &#8212; a reference worth keeping, a project update, a contact &#8212; the briefing includes a nudge asking whether to file it. No automatic filing without approval.</p><h2>What We Learned</h2><blockquote><p><strong>Audit before you install</strong> &#8212; Reading the source before running it cost 10 minutes and gave real confidence. For a tool with read/write access to all your notes, that's time well spent.</p></blockquote><blockquote><p><strong>Build from source when you can</strong> &#8212; The Homebrew tap had v0.2.3. Building from the audited source got us v0.3.1 &#8212; newer, and we know exactly what's in it. The update alias makes staying current low-effort.</p></blockquote><blockquote><p><strong>Git is a better sync layer than you'd expect</strong> &#8212; Obsidian Sync's killer feature is real-time cross-device sync. If you're not actually using multiple devices, git + Obsidian Git gives you history, a remote backup, and programmatic access &#8212; for free.</p></blockquote><blockquote><p><strong>Structure before automation</strong> &#8212; Automating a disorganized vault would have just automated the mess. Getting PARA set up first meant the daily note job had a sensible place to put things from day one.</p></blockquote><div><hr></div><p><strong>Tools:</strong> Obsidian &#183; obsidian-cli &#183; Obsidian Git &#183; GitHub &#183; OpenClaw cron</p><p><strong>Method:</strong> PARA (Tiago Forte)</p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/obsidian-ai-setup">https://www.paulbrennaman.me/lab/obsidian-ai-setup</a></em></p>]]></content:encoded></item><item><title><![CDATA[We Built a Fact System That Memory Search Couldn't Find]]></title><description><![CDATA[We've spent the last few days building a fact-level memory system &#8212; designing it, implementing it, and integrating it into the nightly review job.]]></description><link>https://hackyourway.substack.com/p/facts-searchable-redesign</link><guid isPermaLink="false">https://hackyourway.substack.com/p/facts-searchable-redesign</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Wed, 25 Feb 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/cf637b55-69bd-42b8-9ebc-d8b1b8eb12a4_1536x1024.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We've spent the last few days building a fact-level memory system &#8212; <a href="/lab/fact-level-memory-decay">designing it</a>, <a href="/lab/implementing-fact-memory">implementing it</a>, and integrating it into the nightly review job. 27 atomic facts, lifecycle tracking, temperature decay, WAL compaction. The whole thing. But there was a problem that had gone unnoticed: the facts were stored in <code>items.json</code> files. And <code>memory_search</code> only indexes <code>.md</code> files. Every single fact we'd stored was completely invisible to the tool we use for recall.</p><h2>The Blind Spot</h2><p>OpenClaw's <code>memory_search</code> tool does semantic search over <code>MEMORY.md</code> and <code>memory/**/*.md</code>. It can also be configured to index additional paths via <code>memorySearch.extraPaths</code> &#8212; we already had ours pointed at the PARA directories (<code>knowledge/areas</code>, <code>knowledge/resources</code>, <code>knowledge/projects</code>). But that only picks up <code>.md</code> files. JSON is invisible by design.</p><p>So we had two parallel systems that didn't talk to each other. The <code>.md</code> files were searchable. The <code>items.json</code> fact store &#8212; which had all the structured, atomic, lifecycle-tracked facts &#8212; was not. The only way to query it was to call <code>scripts/facts.py</code> directly, which only runs when explicitly called. Spontaneous recall through <code>memory_search</code> was completely blind to it.</p><p>We could have patched it quickly &#8212; generate a flat <code>facts.md</code> rendering of all active facts, let <code>memory_search</code> pick it up. But that would have been the wrong call. Instead of patching, we asked the better question: what should the structure actually look like?</p><h2>Rethinking the Structure</h2><p>The original PARA layout was flat. Each top-level directory had one <code>items.json</code>:</p><pre><code>knowledge/areas/items.json      # all area facts, mixed together
knowledge/projects/items.json   # all project facts, mixed together
knowledge/resources/items.json  # all resource facts, mixed together
</code></pre><p>This worked fine for the engine &#8212; it could iterate everything. But it didn't reflect how PARA is actually supposed to work. Real PARA systems have nested structure: areas like <em>Career</em>, <em>Finances</em>, <em>Health</em>. Projects like <em>Job Search</em>, <em>Personal Website</em>. Resources like <em>Contacts</em>, <em>How-To Reference</em>.</p><p>Flattening everything into one file per PARA directory was convenient for the first implementation, but it was the wrong shape. And it's why facts couldn't be rendered into something <code>memory_search</code> could use &#8212; a flat dump of 27 mixed facts isn't a useful document for semantic search. It has no structure, no coherence, no reason for related things to be near each other.</p><h2>The Design We Settled On</h2><p>One subdirectory per topic. Each subdirectory gets two files: <code>items.json</code> for metadata, <code>index.md</code> for human-readable content plus a rendered facts section.</p><pre><code>knowledge/areas/career/
    items.json    # career facts metadata
    index.md      # career context + rendered facts

knowledge/areas/tools-and-systems/
    items.json
    index.md

knowledge/projects/job-search/
    items.json
    index.md      # existing content preserved

knowledge/resources/contacts/
    items.json
    index.md
</code></pre><p>Three decisions embedded in this structure that are worth explaining:</p><h3>Decision 1: items.json colocated with index.md</h3><p>The reasoning was practical: the <code>items.json</code> should live in the same subdirectory as the <code>index.md</code>, not at the top level. The reason was practical &#8212; if you're poking around the file system trying to verify something, the JSON and the rendered view should be right next to each other. Everything in <code>career/items.json</code> should appear somewhere in <code>career/index.md</code>. You can check that without opening multiple directories.</p><p>It also makes the global index honest. The global <code>_meta/facts-index.json</code> is rebuilt from all subdirectory <code>items.json</code> files. If something is missing from a subdirectory, it's missing from the global index too &#8212; there's no hidden flat file at the top level to catch strays.</p><h3>Decision 2: Rendered section, not a separate file</h3><p>The facts render into <code>index.md</code> directly &#8212; not into a separate <code>facts.md</code>. This keeps things consolidated. The index file is the single document for that topic: context, notes, and facts all in one place, all indexable together as a semantic unit.</p><p>To make this safe &#8212; so the render step doesn't clobber human-written content &#8212; the facts section is wrapped in HTML comment delimiters:</p><pre><code>&lt;!-- facts:begin --&gt;
## Facts

### Preferences
- Prefers async communication over synchronous meetings
- Prefers documentation written before implementation begins

### Lessons
- Monorepo deployments need separate cache keys per package
&lt;!-- facts:end --&gt;
</code></pre><p>The render step finds those delimiters and replaces only what's between them. Everything outside the block is untouched. Since the nightly job controls all writes to <code>items.json</code>, it can regenerate this section on every run with no drift risk &#8212; the source of truth is always the JSON, and the rendered section always reflects it exactly.</p><h3>Decision 3: Intelligent routing, not fixed buckets</h3><p>When a new fact is created, the engine needs to decide which subdirectory it belongs in. This is where we had to be honest about taxonomy drift &#8212; an LLM making routing decisions from scratch every run would inevitably create new subdirectories arbitrarily and scatter related things into inconsistent places.</p><p>The fix: routing is a one-time decision made at fact creation, stored as a <code>subdir</code> field on the fact itself. The nightly render step just reads where each fact says it lives and renders it there. No re-routing on subsequent runs. To prevent taxonomy explosion, the engine strongly prefers existing subdirectories and requires a high bar for creating new ones.</p><h2>What We Changed in facts.py</h2><p>The Python engine needed four changes to support the new structure:</p><p><code>New storage helpers</code> &#8212; items_path(para_dir, subdir), load_items(para_dir, subdir), save_items(para_dir, subdir, data). iter_all_subdirs() yields (para_dir, subdir) pairs for everything with an items.json.</p><p><code>render command</code> &#8212; facts.py render [--para-dir] [--subdir]. Reads items.json, groups active facts by type, writes or updates the facts:begin/end block in index.md. Creates the file if it doesn't exist.</p><p><code>migrate command</code> &#8212; One-time migration. Reads each flat top-level items.json, routes each fact via infer_subdir(), writes to the correct subdirectory, adds the subdir field, deletes the old file.</p><p><code>--subdir on add</code> &#8212; facts.py add now accepts --subdir (e.g. 'career' or 'areas/career'). If omitted, infer_para_and_subdir() handles routing. The subdir field is stored on the fact for future render passes.</p><p>All iteration-based commands (<code>expire-check</code>, <code>decay</code>, <code>health</code>, <code>rebuild-index</code>, <code>list</code>, <code>search</code>) now use <code>iter_all_subdirs()</code> instead of iterating flat PARA directories. The behavior is identical &#8212; they just walk a tree now instead of a list.</p><h2>The Migration: 27 Facts, 6 Subdirectories</h2><p>Running <code>facts.py migrate</code> distributed all 27 existing facts based on content and type:</p><blockquote><p><strong>areas/career/ &#8212; 3 facts</strong> &#8212; Seniority level, preferred team size, target role type</p></blockquote><blockquote><p><strong>areas/tools-and-systems/ &#8212; 16 facts</strong> &#8212; Notification preferences, cron schedules, deployment pipeline facts</p></blockquote><blockquote><p><strong>projects/active-job-search/ &#8212; 4 facts</strong> &#8212; Upcoming interviews, companies in pipeline, prep notes</p></blockquote><blockquote><p><strong>projects/home-lab/ &#8212; 2 facts</strong> &#8212; Server hostname, OS version deployed</p></blockquote><blockquote><p><strong>projects/general/ &#8212; 2 facts</strong> &#8212; Deferred tasks, housekeeping decisions</p></blockquote><blockquote><p><strong>resources/contacts/ &#8212; 5 facts</strong> &#8212; Recruiters, collaborators, references</p></blockquote><p>Existing flat <code>.md</code> files at the PARA top level also moved into their new subdirectory homes &#8212; content preserved, paths updated. Subdirectories that already had an <code>index.md</code> kept their existing content; the facts block was simply appended inside the delimiters.</p><h2>What It Looks Like Now</h2><p>After running migrate and render, <code>knowledge/areas/health/index.md</code> looks like this:</p><pre><code># Health

[existing health notes here]

&lt;!-- facts:begin --&gt;
## Facts

### Preferences
- Prefers morning workouts before 8am
- Prefers running outdoors over treadmill when weather allows

### Status
- Current training goal is a half-marathon in May
&lt;!-- facts:end --&gt;
</code></pre><p><code>memory_search</code> can find all of this now. A query about workout preferences surfaces the health facts. A query about an upcoming race finds the project facts. The fact system and the search index are finally speaking to each other.</p><h2>Health After Migration</h2><pre><code>Total facts: 27 | Active: 27
Unreconciled: 0 | Dormant: 0
Temperature (ephemeral): hot: 4 | warm: 0 | cold: 0
Overdue expirations: 0
Alerts: none &#128994;
</code></pre><p>No data loss during migration. The nightly job prompt was updated with <code>--subdir</code> routing guidance and a new final step: <code>facts.py render</code> after every run to keep the facts blocks in sync.</p><h2>What We Learned</h2><blockquote><p><strong>A working system and a useful system are different things</strong> &#8212; The fact engine worked perfectly &#8212; creating, expiring, reconciling facts. But working correctly and being actually useful for recall are different bars. A system that stores information I can't retrieve isn't a memory system; it's a write-only log.</p></blockquote><blockquote><p><strong>The right structure matters more than the right content</strong> &#8212; The fix wasn't to add more facts or better routing logic. It was to change the shape of how facts are stored so they naturally align with how recall works. Structure determines retrievability.</p></blockquote><blockquote><p><strong>One question exposes one problem; one problem reveals the design</strong> &#8212; It started with a simple question: where's the .md file? That one observation traced back to a fundamental architectural gap &#8212; facts stored outside the search index. It's a good reminder that questions about missing output are often really questions about missing structure.</p></blockquote><blockquote><p><strong>Colocate what belongs together</strong> &#8212; items.json next to index.md in the same subdirectory means you can validate the system by inspection. 'Does the JSON match the rendered file?' is a question you can answer without tooling. That kind of human-verifiable structure is worth building for.</p></blockquote><div><hr></div><p><strong>Tools:</strong> Python 3 &#183; JSON &#183; Markdown &#183; OpenClaw cron</p><p><strong>Previous post:</strong> <a href="/lab/implementing-fact-memory">Fact-Level Memory Decay, Part 2: Building the Engine &#8594;</a></p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/facts-searchable-redesign">https://www.paulbrennaman.me/lab/facts-searchable-redesign</a></em></p>]]></content:encoded></item><item><title><![CDATA[Fact-Level Memory Decay, Part 1: The Design]]></title><description><![CDATA[A few days ago we wrote about building a memory system for my AI assistant using PARA and file-level decay tracking.]]></description><link>https://hackyourway.substack.com/p/fact-level-memory-decay</link><guid isPermaLink="false">https://hackyourway.substack.com/p/fact-level-memory-decay</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/0ef83c3f-6f53-46a6-bcd7-18ca7741c9b9_1536x1024.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A few days ago we wrote about building a memory system for my AI assistant using PARA and file-level decay tracking. That was progress &#8212; but it didn't take long to spot the fundamental flaw. A file called <code>job-search.md</code> might contain thirty facts. Some of them are from this morning. Some are from three weeks ago. File-level decay treats all of them identically. That's not a memory system &#8212; it's a blunt instrument. So we redesigned it from the ground up to track individual facts.</p><h2>What Is a Fact, Exactly?</h2><p>The first design question was definitional: what's the right atomic unit? Go too granular and every adjective becomes its own entry. Go too coarse and you're back to the same problem &#8212; a compound statement that's half stale and half fresh, but gets treated as one thing.</p><p>The definition I landed on: <strong>one subject, one predicate, one claim.</strong></p><p>The practical test is the <em>and</em>-test. If you find yourself writing "and" to connect two independent ideas, that's two facts. Ask: would you ever need to update one piece without the other? If the answer is yes, they're separate. If updating one always means updating the other, keep them together.</p><blockquote><p><strong>Examples:</strong></p><p>&#9989; "Team demo is scheduled for Friday" &#8212; one fact</p><p>&#9989; "Prefers async standups over live meetings" &#8212; one fact</p><p>&#10060; "Currently job searching and prefers fully remote roles" &#8212; two facts crammed together</p></blockquote><h2>Durable vs. Ephemeral</h2><p>Once you have atomic facts, the next insight is that not all facts decay the same way. Some facts are true until they're contradicted. Others are true until time passes. These need different treatment.</p><p><strong>Durable</strong> &#8212; True until explicitly contradicted. Preferences, long-term decisions, contact details. Exempt from temperature decay &#8212; only invalidated by a newer contradicting assertion. Example: 'Prefers async standups over live meetings'</p><p><strong>Ephemeral</strong> &#8212; Time-bounded by nature. Becomes stale when the window passes, regardless of whether anything contradicted it. Subject to temperature decay. Example: 'Team demo call is on Friday'</p><p>Durable facts don't temperature-decay, but they can go <strong>dormant</strong>: if a durable fact hasn't been referenced in 90 days, it gets flagged as possibly no longer relevant. Not deleted &#8212; just surfaced for review. That's the difference between a preference that's still true but hasn't come up lately, versus a belief that has quietly become outdated.</p><h2>Four Statuses, Not Two</h2><p>The original decay system had two states: hot, warm, cold. But temperature alone can't capture everything that can happen to a fact. I added an explicit <code>status</code> field with four values:</p><blockquote><p><strong>active</strong> &#8212; Currently true and relevant. The default.</p></blockquote><blockquote><p><strong>superseded</strong> &#8212; Replaced by a newer, contradicting fact. Linked to its successor via superseded_by. The chain is preserved &#8212; you can always trace how a belief evolved.</p></blockquote><blockquote><p><strong>expired</strong> &#8212; Was true, but its time window has passed. Different from superseded: nothing contradicted it, the clock just ran out.</p></blockquote><blockquote><p><strong>dormant</strong> &#8212; Durable fact with zero access count for 90+ days. Still believed, just unused. Worth reviewing.</p></blockquote><p>The superseded/expired distinction matters. Superseded means a new claim replaced it. Expired means it was true but its time passed. Both preserve history &#8212; neither deletes anything. Facts are never deleted, only transitioned.</p><h2>Write-Ahead Log + Nightly Compaction</h2><p>The trickiest design question: who creates facts, and when? We landed on both &#8212; but with different trust levels and a reconciliation step.</p><p><strong>Inline during sessions:</strong> When something clearly factual surfaces in conversation, create the fact immediately. Don't wait for the nightly job &#8212; if the session ends abruptly or the job fails, you've lost it. These are high-confidence, user-stated facts. Fast, durable, possibly a little noisy.</p><p><strong>Nightly batch from daily logs:</strong> The nightly review job catches what the session didn't explicitly flag &#8212; inferences, patterns, things mentioned in passing. This is also where deduplication and reconciliation happen.</p><pre><code># The mental model
Inline facts = write-ahead log entries (WAL)
Nightly job = compaction

# Inline says:
"this seems like a fact, save it"

# Nightly says:
"confirmed / superseded / these two are the same thing"
</code></pre><p>Every inline fact gets a <code>reconciled: false</code> flag. The nightly job processes the unreconciled pile, confirms or merges each entry, and flips the flag. This gives compaction a clean entry point &#8212; it only needs to process new WAL entries, not compare every fact against every other fact on every run.</p><h2>Deduplication Without Semantic Search</h2><p>Detecting duplicate facts &#8212; "Prefers async standups" vs. "Dislikes synchronous meetings" &#8212; ideally requires semantic similarity. Vectorized semantic search wasn't available for this purpose. So the dedup strategy has to be practical rather than ideal.</p><p>We settled on a two-layer approach:</p><ol><li><p><strong>Structural keying.</strong> Each fact is indexed by <code>type + subject entity</code> at creation time. New facts only get compared against existing facts in the same bucket &#8212; dramatically smaller comparison space.</p></li><li><p><strong>LLM reasoning on flagged candidates.</strong> The nightly job runs as a Claude agent. When structural keying surfaces candidate duplicates, Claude reads them and reasons about whether they assert the same claim. Output: skip, merge, or supersede. This is Claude thinking, not vector search.</p></li></ol><p>It's not perfect &#8212; pure paraphrases with no structural overlap can still slip through. But inline creation discipline (only log clearly new information) keeps the duplicate rate low enough that this approach is workable in practice.</p><h2>How Do You Know It's Working?</h2><p>A memory system without observable success metrics is just vibes. We defined three layers of measurement:</p><p><strong>Health metrics</strong> &#8212; Is the system functioning correctly? WAL backlog (should be near zero after each nightly run), reconciliation rate, expiration accuracy, nightly job runtime.</p><p><strong>Quality metrics</strong> &#8212; Is the decay signal honest? Temperature distribution should be a pyramid &#8212; some hot, more warm, most cold. If everything is hot, decay isn't running. If everything is cold, bumping is broken.</p><p><strong>Outcome metrics</strong> &#8212; Is it actually helping? Is context being repeated less? Is stale information bleeding into current responses? These are harder to measure but matter most.</p><p>A weekly health digest cron posts a summary to a configured notification channel. Threshold-based alerts handle acute failures without waiting for the weekly report: WAL backlog over 20, any active facts with an expired <code>expires_after</code>, nightly job runtime more than 2&#215; its rolling average.</p><h2>What's Next</h2><p>This is a design, not a running system &#8212; yet. The spec is complete, but the file-level decay index is still what's actually running today. Building the fact-level system requires rewriting the nightly review job, creating the <code>items.json</code> structure across the knowledge base, and establishing the inline fact creation habit.</p><p>The gap between a good design and a working implementation is real. In <a href="/lab/implementing-fact-memory">Part 2</a>, I build it.</p><div><hr></div><p><strong>Concepts:</strong> Write-ahead log &#183; LRU/LFU decay &#183; Knowledge representation &#183; PARA method</p><p><strong>Tools:</strong> OpenClaw &#183; Claude Sonnet &#183; Markdown</p><p><strong>Next:</strong> <a href="/lab/implementing-fact-memory">Part 2: Building the Engine &#8594;</a></p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/fact-level-memory-decay">https://www.paulbrennaman.me/lab/fact-level-memory-decay</a></em></p>]]></content:encoded></item><item><title><![CDATA[Fact-Level Memory Decay, Part 2: Building the Engine]]></title><description><![CDATA[The previous post covered the what and the why &#8212; atomic facts, durable vs.]]></description><link>https://hackyourway.substack.com/p/implementing-fact-memory</link><guid isPermaLink="false">https://hackyourway.substack.com/p/implementing-fact-memory</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/7cdc8eb7-4489-4304-bf4c-1fc790b46000_1536x1024.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The <a href="/lab/fact-level-memory-decay">previous post</a> covered the <em>what</em> and the <em>why</em> &#8212; atomic facts, durable vs. ephemeral classification, WAL compaction, structural deduplication. This post is the <em>how</em>. What we actually built, the decisions made during implementation, and what the system looks like running.</p><h2>What We Had to Build</h2><p>The design spec (<a href="/lab/fact-level-memory-decay">previous post</a>) defined the schema, the rules, and the lifecycle. The goal was to turn that into running infrastructure:</p><ol><li><p>A core engine that creates, queries, supersedes, expires, and decays facts</p></li><li><p>Per-directory storage files alongside the existing PARA knowledge base</p></li><li><p>A global index for cross-directory queries</p></li><li><p>Health metrics and alerting</p></li><li><p>An updated nightly cron job that handles fact extraction, reconciliation, and maintenance</p></li><li><p>Seed data &#8212; bootstrap the system with facts from today's daily log</p></li></ol><h2>The Core Engine: facts.py</h2><p>Hank wrote the engine as a single Python CLI script: <code>scripts/facts.py</code>. Not a web service, not a database, not a framework &#8212; just a script that reads and writes JSON files. Here's why.</p><p>The runtime environment is a Debian container with Python 3 and basic Unix tools. The agent is stateless &#8212; it wakes up, does work, and goes back to sleep. Every session starts fresh. A CLI script that operates on JSON files fits this model perfectly. No daemon to manage, no connection to maintain, no state to corrupt if the agent crashes mid-session.</p><p>The commands map directly to the operations from the design spec:</p><pre><code># Create a fact (inline WAL entry)
facts.py add --content "Prefers async standups over live meetings" --type preference --durability durable

# Structural keying search (dedup check)
facts.py search --type preference --subject "remote"

# Supersede an old fact with a new one
facts.py supersede &lt;old-id&gt; &lt;new-id&gt;

# Nightly maintenance
facts.py expire-check  # flip past-due ephemeral facts to expired
facts.py decay          # recalculate temperature for all facts
facts.py health         # generate health metrics
facts.py rebuild-index  # rebuild the global aggregate index
</code></pre><h2>Storage: Colocated with PARA</h2><p>A key design decision: facts live alongside the knowledge they describe. Each PARA directory gets its own <code>items.json</code>:</p><pre><code>knowledge/projects/items.json    # project-related facts
knowledge/areas/items.json       # area-related facts
knowledge/resources/items.json   # contacts, reference facts
knowledge/archives/items.json    # archived/historical facts
knowledge/_meta/facts-index.json # global aggregate
</code></pre><p>When I create a fact, the engine infers which directory it belongs to based on source file and fact type. A contact goes to <code>resources/</code>. An event goes to <code>projects/</code>. A preference goes to <code>areas/</code>. The mapping isn't perfect &#8212; but it doesn't need to be. The global index exists precisely so queries don't care which directory a fact lives in.</p><h2>Seeding: The First 15 Facts</h2><p>A memory system with zero facts isn't very useful. We seeded the initial facts by reading through today's daily log and extracting every independently true-or-false assertion. Here's a sample:</p><blockquote><p><strong>contact &#183; durable</strong> &#8212; Alex Chen is the lead engineer at Acme and the primary point of contact</p></blockquote><blockquote><p><strong>event &#183; ephemeral</strong> &#8212; Team demo call scheduled for Friday 2026-03-01</p></blockquote><blockquote><p><strong>preference &#183; durable</strong> &#8212; Prefers async standups over live meetings when team is distributed</p></blockquote><blockquote><p><strong>status &#183; durable</strong> &#8212; Nightly review job runs at 2:00 AM daily</p></blockquote><blockquote><p><strong>decision &#183; durable</strong> &#8212; Staging environment does not have access to production credentials</p></blockquote><p>15 facts total. 11 durable, 4 ephemeral. All marked <code>reconciled: false</code> so the first nightly run will process them through the compaction step &#8212; confirming, merging, or flagging any issues.</p><p>One judgment call during seeding: the async standup preference was inferred from context rather than a direct statement. That got <code>confidence: "inferred"</code> instead of <code>"stated"</code>. The distinction matters because the nightly job treats inferred facts with more skepticism during reconciliation.</p><h2>The Nightly Job: Upgraded</h2><p>The existing nightly cron job already handled PARA file updates and file-level decay. We extended it with four new responsibilities:</p><p><strong>Fact extraction</strong> &#8212; Read the daily log, extract atomic facts, dedup-check against existing facts before creating.</p><p><strong>WAL compaction</strong> &#8212; Process all unreconciled facts &#8212; confirm, merge duplicates, resolve conflicts by timestamp order.</p><p><strong>Maintenance</strong> &#8212; Expire past-due ephemeral facts, recalculate temperature decay, check for dormant durable facts.</p><p><strong>Health check</strong> &#8212; Generate metrics, post alerts to a configured notification channel if anything looks wrong.</p><p>The prompt for the nightly job is stored as a Markdown file (<code>knowledge/_meta/nightly-job-prompt.md</code>) rather than hardcoded in the cron config. This means I can iterate on the extraction logic without touching the cron infrastructure. The cron job just reads the file and follows the steps.</p><p>We also bumped the timeout from 300s to 600s. The old job was just reading files and updating a simple index. The new job does LLM reasoning for reconciliation &#8212; comparing candidate duplicates, resolving conflicts, deciding whether two differently-worded claims assert the same thing. That takes more time.</p><h2>Running in Parallel</h2><p>The file-level decay index (<code>decay-index.json</code>) still runs. I didn't rip it out. Both systems operate on the same nightly job &#8212; the old file-level tracking continues exactly as before, and the new fact-level system runs alongside it.</p><p>This was deliberate. The fact-level system is brand new and unproven. If something breaks &#8212; bad extraction, reconciliation bugs, corrupt JSON &#8212; the file-level system is still there as a safety net. Once the fact system has a few weeks of successful runs, we'll deprecate the old one.</p><h2>The Weekly Health Digest</h2><p>We also set up a weekly cron job that posts a health digest every Monday morning to a dedicated notification channel. It reports:</p><ul><li><p>Total facts, broken down by status</p></li><li><p>WAL backlog (unreconciled count &#8212; should be near zero)</p></li><li><p>Temperature distribution for ephemeral facts</p></li><li><p>Dormant durable fact count</p></li><li><p>Nightly job success rate for the past week</p></li><li><p>Any active alerts</p></li></ul><p>The first digest will run next Monday. I'm genuinely curious what the numbers look like after a week of nightly extraction and compaction.</p><h2>What I Decided to Skip</h2><p>The design spec included a <code>memory_recall</code> wrapper &#8212; a layer between Hank and the <code>memory_search</code> tool that would track which retrieved facts I actually used in responses, and only bump access counts on those. The idea was to prevent temperature inflation from facts that were retrieved but never contributed to a response.</p><p>After thinking it through, the wrapper felt unnecessary. Temperature decay is self-correcting: a fact that gets retrieved but never matters will stop being retrieved as its content becomes less relevant to incoming queries. The false bumps wash out naturally. Building a wrapper that intercepts a built-in OpenClaw tool felt like over-engineering a problem that doesn't exist yet.</p><p>If the temperature distribution looks wrong after 100+ facts &#8212; everything stuck at hot, nothing cooling off &#8212; we'll revisit. But not before the data says there's a problem.</p><h2>First Health Check</h2><p>Right after building, I ran the health check to see what the system looks like at birth:</p><pre><code>Total facts: 15 | Active: 15
Unreconciled: 15 (expected &#8212; first nightly run hasn't happened yet)
Temperature (ephemeral): &#128308; hot: 4 | &#128993; warm: 0 | &#128309; cold: 0
Dormant: 0
Alerts: none
</code></pre><p>Everything hot, nothing reconciled, zero history. That's exactly right for a system that was born today. After the first nightly run, the unreconciled count should drop to zero and the first round of extraction from today's daily log should add a few more facts.</p><h2>What We Learned</h2><blockquote><p><strong>JSON files are underrated</strong> &#8212; No database, no schema migrations, no connection pooling. Just files. They're human-readable, git-diffable, and if something goes wrong I can fix them with a text editor. For a system with dozens-to-hundreds of facts, this is the right level of infrastructure.</p></blockquote><blockquote><p><strong>The nightly job prompt is the real product</strong> &#8212; The Python script is just plumbing. The nightly job prompt &#8212; the instructions that tell a future Claude session how to extract, reconcile, and maintain facts &#8212; is where all the intelligence lives. Getting that prompt right matters more than any code I wrote.</p></blockquote><blockquote><p><strong>Parallel running buys confidence</strong> &#8212; Keeping the old file-level system running alongside the new fact-level system costs almost nothing (a few extra lines in the nightly prompt) but gives us a clean rollback path. We'll take that tradeoff every time.</p></blockquote><h2>What Happens Next</h2><p>Tonight at 4 AM ET, the nightly job runs for the first time with the fact-level system enabled. It'll extract facts from today's daily log, reconcile the 15 seed facts, and report any issues.</p><p>Over the next few weeks, we'll watch the temperature distribution evolve as facts age. The time-bounded events will expire. Preferences will stay durable. New facts will accumulate. And at some point, the first supersession will happen &#8212; some belief I hold today will be replaced by a newer one.</p><p>That's when it gets interesting. Not when the system stores facts, but when it learns to let go of old ones.</p><div><hr></div><p><strong>Tools:</strong> Python 3 &#183; JSON &#183; OpenClaw cron &#183; Claude Sonnet (nightly job)</p><p><strong>Design spec:</strong> <a href="/lab/fact-level-memory-decay">Fact-Level Memory Decay, Part 1: The Design</a></p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/implementing-fact-memory">https://www.paulbrennaman.me/lab/implementing-fact-memory</a></em></p>]]></content:encoded></item><item><title><![CDATA[Building an AI Memory System with PARA and Memory Decay]]></title><description><![CDATA[Hank, my AI assistant, writes things down.]]></description><link>https://hackyourway.substack.com/p/para-knowledge-system</link><guid isPermaLink="false">https://hackyourway.substack.com/p/para-knowledge-system</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/cd44aeba-0038-4930-8fe0-4b2e38954924_1536x1024.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Hank, my AI assistant, writes things down. Daily notes, preferences, decisions &#8212; it's all on disk as Markdown. The problem I kept running into: the longer it ran, the more it knew, but the worse it got at organizing what it knew. A flat <code>MEMORY.md</code> file and a folder of daily logs isn't a knowledge system. It's a pile. So I built one.</p><h2>First: Reframing MEMORY.md</h2><p>Before building anything, I had to fix a conceptual problem. My <code>MEMORY.md</code> had become a dumping ground &#8212; project details, contact info, system config, behavioral rules, all mixed together. None of it was organized around the question that actually matters: <em>how do I work with this person?</em></p><p>The new rule: <strong>MEMORY.md is only for patterns, preferences, and rules of engagement.</strong> Not facts about the world. Not project status. Not contacts. Just the distilled understanding of how to work with me specifically &#8212; what I value, how I think, what's gotten the assistant in trouble before, and what I've explicitly asked for.</p><p>Everything else needed a different home.</p><h2>The PARA System</h2><p>I organized the rest using <a href="https://fortelabs.com/blog/para/">Tiago Forte's PARA method</a>, which sorts all information into four buckets:</p><p><strong>Projects</strong> &#8212; Active work with a specific goal and end date. Job search, website work, ongoing builds.</p><p><strong>Areas</strong> &#8212; Ongoing responsibilities with no end date. Career development, the assistant system itself.</p><p><strong>Resources</strong> &#8212; Reference material on topics of interest. Tech notes, contacts, how-to knowledge.</p><p><strong>Archives</strong> &#8212; Completed or inactive items from the other three. Keeps things tidy without deleting anything.</p><p>The knowledge base lives at <code>knowledge/</code> in the assistant workspace, right alongside <code>MEMORY.md</code> and the daily notes &#8212; so the same search tooling (QMD) indexes everything together.</p><h2>Daily Notes &#8594; PARA: The Extraction Schema</h2><p>Hank already keeps a daily log (<code>memory/YYYY-MM-DD.md</code>) &#8212; a fast, raw, append-only record of what happened each day. That stays. But raw notes aren't useful long-term without distillation.</p><p>Each night, Hank's review job reads the day's log and extracts seven categories of durable facts:</p><ol><li><p><strong>Key decisions made</strong> &#8212; anything that changed direction or set a constraint</p></li><li><p><strong>Projects discussed</strong> &#8212; progress, blockers, next steps</p></li><li><p><strong>People mentioned</strong> &#8212; who came up and in what context</p></li><li><p><strong>Status changes</strong> &#8212; started, completed, blocked, archived</p></li><li><p><strong>Lessons learned / gotchas</strong> &#8212; things that went wrong or produced insight</p></li><li><p><strong>Preferences stated</strong> &#8212; any explicit statement about how I want things done</p></li><li><p><strong>Open questions / follow-ups</strong> &#8212; unresolved things that need to come back up</p></li></ol><p>Items 1&#8211;5 feed the PARA files. Items 6&#8211;7 feed <code>MEMORY.md</code> directly &#8212; those are behavioral facts, not project facts.</p><blockquote><p><strong>Why not capture directly into PARA?</strong> Speed. The daily note is zero-friction &#8212; just log everything as it happens. Deciding where something belongs in PARA requires judgment, and doing that mid-conversation slows things down. The nightly job handles organization when there's no urgency.</p></blockquote><h2>Memory Decay</h2><p>Not all memories are equally relevant. A decision from this morning matters more than a note from four months ago &#8212; even if the older note is a better semantic match in search.</p><p>We track every file in the knowledge base in a decay index with three fields: <code>last_accessed</code>, <code>access_count</code>, and <code>temperature</code>. Temperature has three states:</p><blockquote><p><strong>&#128293; Hot &#8212; Accessed in the last 7 days</strong> &#8212; Featured prominently in summaries and context.</p></blockquote><blockquote><p><strong>&#127777; Warm &#8212; 8&#8211;30 days since last access</strong> &#8212; Included but deprioritized.</p></blockquote><blockquote><p><strong>&#129482; Cold &#8212; 30+ days since last access</strong> &#8212; Dropped from active summaries but never deleted. Still searchable.</p></blockquote><p>The twist: <strong>frequency resistance.</strong> Files that have been accessed many times require longer inactivity before they cool down. A note you've referenced 20 times stays warm much longer than one you read once. High-signal facts resist decay; incidental ones fade faster.</p><p>Cold facts are never deleted. When something cold becomes relevant again &#8212; via search, conversation, or the nightly review &#8212; it gets "reheated": <code>last_accessed</code> resets, the clock starts over.</p><h2>The Nightly Review Job</h2><p>Everything above runs automatically. At midnight, Hank spins up as an isolated sub-agent that:</p><ol><li><p>Reads today's daily note</p></li><li><p>Runs the 7-category extraction</p></li><li><p>Updates the relevant PARA files</p></li><li><p>Updates <code>MEMORY.md</code> if new behavioral patterns emerged</p></li><li><p>Recalculates decay temperatures across the entire knowledge base</p></li></ol><p>It runs silently &#8212; no notification unless something urgent surfaces. I wake up with the knowledge base already updated.</p><h2>How It All Fits Together</h2><pre><code># During the day
Conversation happens &#8594; raw notes go to memory/YYYY-MM-DD.md

# At midnight
Nightly job reads daily note &#8594; extracts 7 categories
&#8594; updates knowledge/projects/, areas/, resources/
&#8594; updates MEMORY.md (behavior/preferences only)
&#8594; recalculates decay temperatures

# On any search
QMD searches all of: MEMORY.md + memory/ + knowledge/
Hot facts surface first, cold facts fade from summaries
</code></pre><h2>Early Impressions</h2><p>The system ran for the first time last night. It's early, but the structure already feels right. Having a clear place for everything &#8212; and a rule for what belongs in <code>MEMORY.md</code> vs. the knowledge base &#8212; removes a lot of ambiguity about where things should go.</p><p>The decay system is the part I'm most curious about long-term. The real test is six months from now: does old context gracefully fade while the stuff that actually matters stays warm? I'll write a follow-up when I have a real answer.</p><div><hr></div><p><strong>Inspired by:</strong> Tiago Forte's PARA Method, LRU/LFU cache algorithms</p><p><strong>Tools:</strong> OpenClaw &#183; QMD &#183; Claude Sonnet &#183; Markdown</p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/para-knowledge-system">https://www.paulbrennaman.me/lab/para-knowledge-system</a></em></p>]]></content:encoded></item><item><title><![CDATA[Connecting OpenClaw's Browser Relay to a Remote Gateway]]></title><description><![CDATA[OpenClaw's browser relay lets an AI agent control your existing Chrome tabs &#8212; not a separate isolated browser, but the real one you're already logged into.]]></description><link>https://hackyourway.substack.com/p/browser-relay-remote-gateway</link><guid isPermaLink="false">https://hackyourway.substack.com/p/browser-relay-remote-gateway</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!fyVV!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71eb1bf2-e4c7-45f1-8ca1-dbc743f087b7_636x636.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>OpenClaw's browser relay lets an AI agent control your existing Chrome tabs &#8212; not a separate isolated browser, but the real one you're already logged into. When your gateway runs locally, it just works. When your gateway runs on a remote VPS, there are a few things that need to click into place first. Here's exactly what they are.</p><h2>The Setup</h2><p>My OpenClaw gateway runs on a cloud VM, exposed over Tailscale (HTTPS, MagicDNS). My Mac is where Chrome lives. The goal: have the agent &#8212; running on the server &#8212; control Chrome tabs on my Mac via the browser relay extension.</p><p>The architecture has three moving parts:</p><p><strong>Gateway (remote VPS)</strong> &#8212; The AI agent runs here. It proxies browser tool calls to the node host.</p><p><strong>Node host (Mac)</strong> &#8212; A lightweight OpenClaw process that runs on the machine with Chrome. Handles browser requests proxied from the gateway and manages the local relay server.</p><p><strong>Chrome extension + relay</strong> &#8212; The extension attaches to a Chrome tab. A local relay server (127.0.0.1:18792) bridges the extension and the node host.</p><h2>Starting the Node Host</h2><p>On the Mac, you run a node host that connects back to the remote gateway:</p><pre><code>openclaw node run --host your-gateway.ts.net --port 443 --tls
</code></pre><p>This connects the Mac to the gateway. On first run, you'll need to approve the pairing from the server side:</p><pre><code>openclaw nodes pending
openclaw nodes approve &lt;requestId&gt;
</code></pre><p>Once paired, the node shows up as connected with browser and system capabilities. The node host process is foreground-only &#8212; it holds the terminal. Keep a second tab handy for everything else.</p><h2>Installing the Extension</h2><p>Install the extension files to a stable local path, then load them into Chrome:</p><pre><code>openclaw browser extension install
openclaw browser extension path  # prints the directory
</code></pre><p>In Chrome: go to <code>chrome://extensions</code>, enable Developer mode, click Load unpacked, and select the directory from above. Pin the extension.</p><p><strong>Tip:</strong> If the macOS file picker isn't showing hidden folders, press &#8984; Shift . to toggle them.</p><h2>The Three Gotchas</h2><p>Everything above is documented. What isn't well-documented is why the extension shows an error even after you do everything right. Hank and I worked through these together &#8212; there are three independent issues, and you need to resolve all of them.</p><h3>1. The relay starts lazily</h3><p>When the gateway is local, it starts the relay server (port 18792) on boot. When the gateway is remote, the node host is responsible for the relay &#8212; but it only starts it when the gateway sends the first browser request. Until then, port 18792 is closed.</p><p>This means: even with the node host running and connected, the extension will show <code>Relay not reachable</code> in its options page until something triggers the relay to start.</p><p>The fix is simple &#8212; just send any browser status request from the server side. This kicks the node host into starting the relay:</p><pre><code>openclaw browser --browser-profile chrome status
</code></pre><p>After that, <code>lsof -i :18792</code> should show something listening. The extension options page should now say the relay is reachable.</p><h3>2. The node host needs the gateway auth token</h3><p>Once the relay is running, the extension will still reject your token with <code>Gateway token rejected</code> &#8212; even if you're pasting the correct token from the server's config.</p><p>The reason: the relay validates incoming tokens against the gateway auth token. When the relay runs on the node host (not the gateway), it needs to know that token too &#8212; but the node host only has a <code>node.json</code> with its pairing credentials. It has no <code>openclaw.json</code> unless you create one.</p><p>Fix: create <code>~/.openclaw/openclaw.json</code> on the Mac with the gateway auth token:</p><pre><code>{
  "gateway": {
    "auth": {
      "token": "&lt;your-gateway-token&gt;"
    }
  }
}
</code></pre><p>Grab the token from the server: <code>cat ~/.openclaw/openclaw.json</code> and look for <code>gateway.auth.token</code>. Don't share it anywhere &#8212; treat it like a password.</p><p>After creating the file: restart the node host, trigger the relay again (step 1), then retry saving in the extension options. It should accept the token this time.</p><h3>3. Playwright needs to be installed</h3><p>The relay and browser proxy require Playwright on the node host machine. If it's not installed, the relay either won't start or certain browser operations will fail with a 501.</p><pre><code>npm install -g playwright
</code></pre><p>Running <code>npx playwright --version</code> isn't enough &#8212; <code>npx</code> downloads on demand and isn't available to the node host process. A global install is what you want.</p><h2>Attaching to a Tab</h2><p>Once the extension is configured and the relay is running, click the extension icon on any Chrome tab. The badge shows <strong>ON</strong> when attached. The agent now has eyes (and hands) on that tab.</p><p>Only the tabs you explicitly attach are controllable. Detach by clicking the icon again. If you want to switch to a different tab, open it and click the icon there.</p><h2>Verifying It Works</h2><p>From the server (or in an agent session), take a snapshot of the attached tab:</p><pre><code>openclaw browser --browser-profile chrome snapshot
</code></pre><p>You should get back a full accessibility tree of whatever tab is open. From there, the agent can click, type, navigate, fill forms, and read page content &#8212; all through your real, logged-in Chrome session.</p><blockquote><p><strong>Security note:</strong> This gives the agent access to whatever the attached tab is logged into. Keep the gateway and node host on a private network (Tailscale works well). Don't expose relay ports over LAN or public internet.</p></blockquote><h2>Summary</h2><ol><li><p>Start the node host on your Mac, pointing at your remote gateway</p></li><li><p>Install the Chrome extension and load it unpacked</p></li><li><p>Trigger any browser request from the server to lazily start the relay</p></li><li><p>Create ~/.openclaw/openclaw.json on the Mac with the gateway auth token</p></li><li><p>Install Playwright globally on the Mac</p></li><li><p>Restart the node host, re-trigger the relay, save the token in extension options</p></li><li><p>Click the extension icon on a tab &#8212; badge ON means you're good</p></li></ol><div><hr></div><p><strong>Tools:</strong> OpenClaw &#183; Chrome Extension &#183; Tailscale &#183; Playwright &#183; Node.js</p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/browser-relay-remote-gateway">https://www.paulbrennaman.me/lab/browser-relay-remote-gateway</a></em></p>]]></content:encoded></item><item><title><![CDATA[Upgrading OpenClaw's Memory with QMD]]></title><description><![CDATA[The longer my AI assistant has been running, the smarter it should get &#8212; more context, more history, more remembered preferences.]]></description><link>https://hackyourway.substack.com/p/qmd-memory-search</link><guid isPermaLink="false">https://hackyourway.substack.com/p/qmd-memory-search</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/7a5771bc-aaec-4c72-9c07-5fba16b0d162_1536x1024.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The longer my AI assistant has been running, the smarter it should get &#8212; more context, more history, more remembered preferences. The problem is that "more memory" only helps if it can actually find what it stored. I hit a wall where my assistant was clearly forgetting things that were written down, and I went digging for a fix.</p><h2>The Problem with Default Memory Search</h2><p>OpenClaw stores memory as plain Markdown files &#8212; a daily log, a long-term MEMORY.md, whatever you write to disk. The built-in search indexes those files in SQLite and does its best, but it's largely keyword-based. That means if you search for "gateway server setup" and your note says "run the gateway on the Mac Mini in the closet," you get nothing. The words don't match, so the memory doesn't surface.</p><p>As memory grows, this gets worse. Weeks of daily notes, preferences, project decisions &#8212; all technically there, increasingly unfindable.</p><h2>Enter QMD</h2><p>QMD (Query Markup Documents) is a local search engine for Markdown files built by <a href="https://github.com/tobi">Tobi L&#252;tke</a> (founder of Shopify). It combines three search strategies:</p><ul><li><p><strong>BM25 full-text search</strong> &#8212; fast keyword matching, great for exact terms, IDs, error messages</p></li><li><p><strong>Vector semantic search</strong> &#8212; finds conceptually similar content even when wording differs</p></li><li><p><strong>LLM re-ranking</strong> &#8212; merges both result sets and re-ranks them with a local model for best quality</p></li></ul><p>The whole thing runs locally via <code>node-llama-cpp</code> with small GGUF models. No API keys, no cloud, no data leaving your machine. OpenClaw already supports QMD as a first-class memory backend &#8212; you just have to turn it on.</p><p><strong>Important caveat:</strong> layers 2 and 3 require a CUDA-capable GPU to be usable in practice. On CPU-only hardware, only BM25 is viable. More on this below.</p><blockquote><p>One claimed benefit: 95%+ reduction in token usage, because retrieval moves off the LLM and onto your machine. Instead of stuffing large chunks of memory into context hoping the model finds the relevant bit, QMD returns only the right snippets.</p></blockquote><h2>The Setup</h2><p>OpenClaw natively supports QMD as a drop-in memory backend. Here's how to enable it.</p><h3>1. Install QMD</h3><p>Install the QMD CLI globally via npm (or bun if you have it):</p><pre><code>npm install -g @tobilu/qmd --prefix ~/.npm-global
</code></pre><p>Verify it installed:</p><pre><code>~/.npm-global/bin/qmd --version
# qmd 1.0.7
</code></pre><h3>2. Update OpenClaw config</h3><p>Add a <code>memory</code> section to <code>~/.openclaw/openclaw.json</code>:</p><pre><code>"memory": {
  "backend": "qmd",
  "qmd": {
    "includeDefaultMemory": true,
    "update": { "interval": "5m" },
    "limits": { "maxResults": 6 },
    "scope": {
      "default": "deny",
      "rules": [
        { "action": "allow", "match": { "chatType": "direct" } }
      ]
    }
  }
}
</code></pre><p>The <code>scope</code> config is important &#8212; it restricts QMD memory results to direct/private chats only, so your personal memory doesn't leak into group conversations.</p><h3>3. Restart the gateway</h3><pre><code>openclaw gateway restart
</code></pre><h2>The Gotcha: PATH</h2><p>After restarting, we checked the logs and found this repeating:</p><pre><code>qmd collection add failed: spawn qmd ENOENT
</code></pre><p>OpenClaw couldn't find the <code>qmd</code> binary. The issue: the gateway runs as a systemd service with its own <code>PATH</code> environment variable, and it didn't include the directory where npm installed the binary.</p><p>The fix is making sure you install QMD into a directory that's already on the gateway's PATH. Check your gateway service PATH with:</p><pre><code>cat ~/.config/systemd/user/openclaw-gateway.service | grep PATH
</code></pre><p>Then install QMD into a directory from that list &#8212; in my case <code>~/.npm-global/bin</code> was already included, so using <code>--prefix ~/.npm-global</code> during install put the binary exactly where the gateway could find it.</p><h2>Verifying It Works</h2><p>Once installed correctly, the gateway logs on restart should look clean &#8212; no ENOENT errors, just:</p><pre><code>qmd memory startup initialization armed for agent "main"
</code></pre><p>And you can confirm QMD is actively running:</p><pre><code>ps aux | grep qmd
# node .../qmd.js embed &#8592; indexing your memory files
</code></pre><p>The first-time setup downloads about 2GB of local GGUF models (embedding, reranker, query expansion) &#8212; one-time cost, happens automatically in the background.</p><h2>A Note on Graceful Fallback</h2><p>One thing I appreciated: if QMD isn't working (binary missing, crash, whatever), OpenClaw silently falls back to the built-in SQLite search. Nothing breaks. You just don't get the upgraded search until it's fixed. That made the whole setup process low-risk &#8212; I could debug without losing memory functionality in the meantime.</p><h2>Hardware Reality Check</h2><p>After getting QMD running, we wanted to verify that all three search layers actually worked &#8212; not just assume they did because the setup completed. Here's what we found on my server (GCP e2-medium: 2 vCPUs, 4 GB RAM, no GPU):</p><p><strong>Layer 1 &#8212; BM25 &#9989;</strong> &#8212; Fast, lightweight, no model needed. Returns results in milliseconds. This is what OpenClaw uses by default.</p><p><strong>Layer 2 &#8212; Vector semantic search &#10060;</strong> &#8212; Even qmd vsearch (advertised as 'no reranking') still loads the 1.7B query expansion model before doing vector similarity. On CPU, it pegged both cores at 100% for 15+ minutes and consumed 2.7 GB of 4 GB RAM before I killed it.</p><p><strong>Layer 3 &#8212; LLM re-ranking &#10060;</strong> &#8212; Same constraint as layer 2, but heavier. Requires the full query expansion + reranking pipeline. Completely impractical without GPU acceleration.</p><p>The root cause: QMD detects CUDA, tries to initialize it, fails (no GPU), and falls back to CPU via a try/catch in its internals. The fallback works &#8212; it doesn't crash &#8212; but CPU inference on a 1.7B model is just too slow for interactive use.</p><blockquote><p><strong>What hardware would make layers 2 &amp; 3 work?</strong></p><p>You need a CUDA-capable NVIDIA GPU. In cloud terms, that means something like a GCP <strong>g2-standard-4</strong> (NVIDIA L4) or an AWS <strong>g4dn.xlarge</strong> (NVIDIA T4). A local desktop or workstation with a modern NVIDIA GPU (RTX 3060 or better) would also be more than sufficient. The models QMD uses are small enough that even a mid-range GPU handles them comfortably. The e2-medium class of instance &#8212; and any CPU-only VPS &#8212; simply isn't the right fit.</p></blockquote><h2>Worth It?</h2><p>If you have a GPU: almost certainly yes. The full pipeline &#8212; query expansion, vector similarity, LLM re-ranking &#8212; is exactly the kind of retrieval upgrade that makes a real difference as memory grows.</p><p>If you're on CPU-only hardware like I am: the honest answer is that you're getting BM25 with extra steps. That's not nothing &#8212; QMD's BM25 indexes your files cleanly and integrates tightly with OpenClaw's memory system. But the headline feature (semantic search) won't work until you add a GPU to the picture.</p><p>We got through the setup in about 30 minutes, including working through the PATH issue. And BM25 alone is a solid, reliable memory layer &#8212; just go in with accurate expectations about what the hardware you're running on can actually deliver.</p><div><hr></div><p><strong>Tools:</strong> OpenClaw &#183; QMD &#183; node-llama-cpp &#183; systemd</p><p><strong>Models:</strong> embedding-gemma-300M &#183; qwen3-reranker-0.6b &#183; qmd-query-expansion-1.7B</p><p><em>Originally published at <a href="https://www.paulbrennaman.me/lab/qmd-memory-search">https://www.paulbrennaman.me/lab/qmd-memory-search</a></em></p>]]></content:encoded></item><item><title><![CDATA[Getting Started with OpenClaw: Building a Personal AI Assistant]]></title><description><![CDATA[I&#8217;ve been building Hank &#8212; a personal AI assistant running on OpenClaw &#8212; that integrates with real-world tools and services.]]></description><link>https://hackyourway.substack.com/p/ai-assistant-automation</link><guid isPermaLink="false">https://hackyourway.substack.com/p/ai-assistant-automation</guid><dc:creator><![CDATA[Paul Brennaman]]></dc:creator><pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/f4b5f29f-d303-4c6b-849f-f620f8dfc721_1400x1000.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I&#8217;ve been building <strong>Hank</strong> &#8212; a personal AI assistant running on <strong><a href="https://openclaw.ai/">OpenClaw</a></strong> &#8212; that integrates with real-world tools and services. Instead of treating AI as just a chatbot, Hank can execute commands, manage infrastructure, and automate workflows across Google Workspace, GitHub, and more.</p><p>OpenClaw is the platform that makes this possible: it gives AI agents persistent memory, tool access, scheduled tasks, and a growing number of skills available through <strong><a href="https://clawhub.ai/">ClawHub</a></strong> &#8212; thousands of pre-built capabilities you can drop straight into your agent, or have the agent do it. Hank is my instance of that &#8212; my assistant, with my tools and skills, running in my infrastructure &#8212; available to act on my behalf: on a fixed schedule or proactively when human intent calls for it.</p><blockquote><p><em><strong>Like a soldier acting on a commander&#8217;s intent, Hank doesn&#8217;t wait to be told every step &#8212; it acts within the spirit of the standing order.</strong></em></p></blockquote><h2><strong>Talking to Hank</strong></h2><p>I interact with Hank through Telegram &#8212; the same app I use to message friends. No special interface, no OpenClaw app to install, no AI provider portal to log into.</p><p>That&#8217;s by design. OpenClaw supports WhatsApp, iMessage, Discord, Slack, Teams, and more. You pick the channel you already live in, and your agent meets you there. You can even specify which LLM to use for a given conversation &#8212; swap to a more powerful model for a complex task, or a faster one from a different provider for a quick question.</p><p>This matters more than it might sound. Most AI tools lock you into their interface &#8212; a browser tab, a mobile app, a proprietary chat window. With OpenClaw, you&#8217;re not switching contexts to use your assistant. You&#8217;re just... messaging it. The same way you&#8217;d message a colleague or friend a quick question.</p><p>It also means Hank is genuinely mobile. Standing in line, sitting on the couch, walking to the car &#8212; if I can send a message, I can put Hank to work.</p><h2><strong>What I Built</strong></h2><p>Hank, my OpenClaw-powered AI assistant, can:</p><ul><li><p><strong>Manage Google Workspace</strong>: Send emails, check calendar availability, create contacts, and search Drive &#8212; all via the <strong><a href="https://gogcli.sh/">gog CLI</a></strong></p></li><li><p><strong>Automate GitHub workflows</strong>: Clone repos, create branches, make code changes, and submit pull requests on my behalf</p></li><li><p><strong>Update this website</strong>: Hank wrote the About page content of my <strong><a href="https://paulbrennaman.me/about">personal website</a></strong> by reading my resume from Google Drive and creating a PR with professionally-written copy</p></li><li><p><strong>Search and fetch web content</strong>: Research topics, fetch documentation, and summarize findings</p></li></ul><p>This is just scratching the surface of what&#8217;s possible &#8212; but it gives you a feel for the range.</p><h2><strong>Why This Matters</strong></h2><p>Traditional automation requires writing scripts for every task. Even sophisticated tools like Zapier or N8N require you to anticipate every scenario upfront &#8212; and the moment your workflow doesn&#8217;t fit a predefined template, you&#8217;re back to writing code.</p><p>With an OpenClaw agent that understands natural language and has access to CLI tools as well as the memory history it has of working with you, the interaction model flips entirely. I can tell Hank &#8220;update my About page based on my resume and other details you know about me&#8221; and Hank handles:</p><ol><li><p>Reading the resume from Google Drive</p></li><li><p>Reading your memory files &#8212; MEMORY.md, USER.md, daily notes, knowledge base &#8212; to pull in context the resume doesn&#8217;t capture</p></li><li><p>Analyzing and reconciling both sources</p></li><li><p>Cloning the personal website repo</p></li><li><p>Making appropriate edits to multiple files</p></li><li><p>Creating a PR with a detailed description</p></li></ol><p>All in one conversational exchange &#8212; no template to configure, no workflow to diagram, no brittle automation to maintain.</p><p>The deeper implication is that <strong>the interface becomes the capability</strong>. If you can describe what you want, the agent figures out how to do it. That fundamentally changes what &#8220;automation&#8221; means.</p><h2><strong>Technical Architecture</strong></h2><h5><strong>Core Stack</strong></h5><p>OpenClaw (agentic AI platform), Telegram (chat channel), Claude Sonnet 4.6 (primary model), Claude Opus 4.6 (heavy coding tasks), ChatGPT (fallback).</p><h5><strong>Integrations</strong></h5><p>What I&#8217;ve connected so far: Google Workspace via gog CLI (Gmail, Calendar, Drive, Contacts); GitHub via gh CLI (repos, PRs, issues, actions); web search, content fetching, web page interactions (Brave Search API, web_fetch, Playwright CLI).</p><h5><strong>Infrastructure</strong></h5><p>Runs on Google Cloud Compute Engine (24/7 uptime). Previously ran in a Docker container on my local laptop &#8212; moved to GCP so Hank stays available even when my machine is off.</p><h2><strong>Why I&#8217;m Excited About This</strong></h2><p>It&#8217;s not the AI part that excites me. Talking to an LLM? That&#8217;s table stakes at this point.</p><p>What&#8217;s different with OpenClaw is everything around it.</p><p>The fact that it meets me in <strong>Telegram</strong> &#8212; the same app I already use &#8212; instead of asking me to open another tab or download another tool. The fact that I can swap models mid-conversation, pulling in GPT, Claude, or Grok depending on what I need. The fact that it builds a <strong>history with me</strong> &#8212; it knows how I work, what I&#8217;ve done, what I care about &#8212; and that context compounds over time.</p><p>And then there&#8217;s the <strong>autonomous piece</strong>. Not just answering questions when I ask, but acting on standing orders. Knowing when to do something without me having to trigger it.</p><p>But honestly, what might excite me most is the <strong>community</strong>. OpenClaw has a groundswell around it that is special &#8212; thousands building skills on <a href="https://clawhub.ai">ClawHub</a>, sharing their setups and lessons learned, extending each other&#8217;s work. That kind of momentum is rare and it usually means something.</p><p>I&#8217;m still finding words for all of it. That&#8217;s kind of the point &#8212; I&#8217;m in the middle of it.</p><h2><strong>What&#8217;s Next</strong></h2><p>This is early-stage experimentation, but the direction is clear. I&#8217;m exploring:</p><ul><li><p><strong>More integrations</strong> &#8212; connecting Hank to more of the tools I use daily</p></li><li><p><strong>Scheduled automation</strong> &#8212; having Hank proactively handle recurring tasks without me asking</p></li><li><p><strong>Autonomous actions</strong> &#8212; giving Hank standing orders and letting it decide when to act on them, not just when I or a schedule triggers it</p></li><li><p><strong>Memory and context</strong> &#8212; how agents retain and use knowledge about you over time</p></li><li><p><strong>Multi-agent workflows</strong> &#8212; specialized agents working together on complex tasks</p></li></ul><p>I&#8217;ll be sharing more on each of these as the work progresses.</p>]]></content:encoded></item></channel></rss>