<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Posts on blog.taikiito.com</title><link>https://blog.taikiito.com/en/posts/</link><description>Recent content in Posts on blog.taikiito.com</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Sat, 22 Aug 2026 09:00:00 +0800</lastBuildDate><atom:link href="https://blog.taikiito.com/en/posts/index.xml" rel="self" type="application/rss+xml"/><item><title>I Built This Blog a Hatena-Style Calendar, and Locked My Own JSON In a Box</title><link>https://blog.taikiito.com/en/posts/2026-08-22-sidebar-calendar/</link><pubDate>Sat, 22 Aug 2026 09:00:00 +0800</pubDate><guid>https://blog.taikiito.com/en/posts/2026-08-22-sidebar-calendar/</guid><description>A record of building a Hatena-Blog-style calendar, tag cloud, and monthly jump buttons into this blog&amp;rsquo;s own sidebar - including tracking down why the calendar silently failed to render, caused by Hugo&amp;rsquo;s html/template auto-escaping mangling an inline JSON blob.</description><content:encoded><![CDATA[<p>Last time, I wrote about shipping a Singlish glossary site alongside this blog. Back to the blog itself this time - a record of building its sidebar.</p>
<h2 id="the-post-list-and-archive-page-werent-quite-enough">The post list and archive page weren&rsquo;t quite enough</h2>
<p>This blog runs on Hugo + PaperMod. At first the header just had two menu items: &ldquo;Posts&rdquo; and &ldquo;Archive.&rdquo; As the post count grows, being able to browse by date or tag helps. PaperMod is a single-column theme though - there&rsquo;s no concept of a sidebar built in. The content column is fixed at a max width (<code>--nav-width: 1024px</code>) and centered, so on a wide screen there&rsquo;s blank space on either side. I figured something could go there.</p>
<h2 id="first-pass-just-two-buttons">First pass: just two buttons</h2>
<p>PaperMod ships an empty hook partial called <code>extend_footer.html</code> - override it and you can inject arbitrary HTML at the end of every page. There&rsquo;s also a convention where any CSS file dropped in <code>assets/css/extended/*.css</code> gets auto-concatenated into the theme&rsquo;s stylesheet. Using both, I added two fixed pill buttons in the right margin: &ldquo;📅 By date&rdquo; linking to <code>/archives/</code>, and &ldquo;🏷️ By tag&rdquo; linking to <code>/tags/</code>. Just shortcuts, nothing more.</p>
<h2 id="looking-at-hatena-blog-i-decided-to-rebuild-it">Looking at Hatena Blog, I decided to rebuild it</h2>
<p>Plain buttons felt thin. Hatena Blog&rsquo;s sidebar has an actual month-grid calendar with post days highlighted, and a tag cloud with names and counts sitting right there. I wanted that instead.</p>
<p>The tag cloud was easy - PaperMod&rsquo;s own <code>/tags/</code> page (<code>taxonomy.html</code>) already does exactly this, so I borrowed its logic directly:</p>
<pre tabindex="0"><code class="language-gotemplate" data-lang="gotemplate">{{ range $tagsPage.Data.Terms.Alphabetical }}
  {{ with site.GetPage (printf &#34;/tags/%s&#34; .Name) }}
    &lt;a href=&#34;{{ .Permalink }}&#34;&gt;{{ .LinkTitle }} ({{ $count }})&lt;/a&gt;
  {{ end }}
{{ end }}
</code></pre><p>The calendar took more thought since this is a static site generator. At build time, I embed each language&rsquo;s post dates, titles, and URLs as JSON inside the page (<code>{{ $calItems | jsonify }}</code>), then render the month grid with plain vanilla JS. No external library.</p>
<h2 id="the-calendar-rendered-nothing">The calendar rendered nothing</h2>
<p>I shipped it, and the tag cloud showed up fine - but the calendar area was just blank. The prev/next (‹ ›) buttons appeared; no day grid, no month title.</p>
<p>Checking the actual rendered HTML in devtools, I found this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;application/json&#34;</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;quick-cal-data&#34;</span><span class="p">&gt;</span><span class="s2">&#34;[{\&#34;date\&#34;:\&#34;2026-08-21\&#34;,\&#34;...\&#34;}]&#34;</span><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span></code></pre></div><p>The whole array had been escaped and wrapped in quotes <strong>as a string</strong>. Feed that into <code>JSON.parse()</code> and you get back a single string, not an array. The <code>.forEach()</code> right after it obviously doesn&rsquo;t exist on a string, so it threw and halted the rest of the script.</p>
<p>The cause was Hugo&rsquo;s template engine (<code>html/template</code>). When you interpolate a value inside a <code>&lt;script&gt;</code> tag, Hugo automatically treats it as untrusted and escapes it as a JS string literal. <code>jsonify</code> returns a plain <code>string</code>, so Hugo wrapped the whole thing in quotes rather than trusting it as raw JS.</p>
<p>The fix was one line - pipe it through <code>safeJS</code> to tell Hugo explicitly that this JS is safe to pass through as-is:</p>
<pre tabindex="0"><code class="language-gotemplate" data-lang="gotemplate">{{ $calItems | jsonify | safeJS }}
</code></pre><p>With that, the JSON landed as a real array again, and the calendar started working.</p>
<h2 id="adding-monthly-jump-buttons">Adding monthly jump buttons</h2>
<p>Once the calendar worked, I wanted a way to jump straight to a given month instead of stepping through prev/next one month at a time - which gets tedious as posts accumulate. I added a list of every year-month with at least one post, newest first; clicking one jumps the calendar there in place, no page reload, reusing the same state the prev/next buttons already manage.</p>
<h2 id="where-things-stand">Where things stand</h2>
<p>This post ends up being a record of the blog fixing itself while I was building it. Calendar, monthly buttons, and tag cloud are all in the sidebar now - next up is actually writing more posts.</p>
<p>To be continued.</p>
]]></content:encoded></item><item><title>Riding the Momentum of Starting the Blog, I Also Shipped a Singlish Glossary Site</title><link>https://blog.taikiito.com/en/posts/2026-08-22-singlish-lah/</link><pubDate>Sat, 22 Aug 2026 08:30:00 +0800</pubDate><guid>https://blog.taikiito.com/en/posts/2026-08-22-singlish-lah/</guid><description>A small side project shipped the same day as this blog: a plain HTML/CSS/JS Singlish-term glossary for Japanese speakers, plus the subdomain + repo pattern that later projects would reuse.</description><content:encoded><![CDATA[<p>Last post was about building the shell of this blog. Same day, riding the momentum, I shipped one more small site: a glossary of Singlish expressions you actually hear in daily conversation in Singapore, aimed at Japanese speakers.</p>
<h2 id="deliberately-a-different-stack">Deliberately a different stack</h2>
<p>The blog runs on Hugo, but this one is plain HTML/CSS/JS with no build step at all. If I&rsquo;m going to put projects out as a portfolio, showing range in approach felt better than reaching for the same stack every time. Data lives in a <code>terms.json</code> file, with search and category filtering built on top of it. At launch: 33 terms across 5 categories (stock phrases, reactions/emotions, food &amp; daily life, relationships/how people address each other, and things picked up at work).</p>
<p>Content is a mix of generally well-known Singlish terms plus a few I&rsquo;ve actually heard myself. The plan going forward is to keep appending to <code>terms.json</code> as new ones come up.</p>
<h2 id="the-subdomain-pattern-became-a-real-pattern">The subdomain pattern became a real pattern</h2>
<p>The domain-and-repo shape settled into an actual template at this point: one dedicated GitHub repo per project, one <code>&lt;project&gt;.taikiito.com</code> subdomain via a Cloudflare CNAME. One thing I learned here: the CNAME has to stay DNS-only (not proxied), or GitHub Pages&rsquo; automatic HTTPS certificate issuance gets delayed or fails outright.</p>
<h2 id="back-and-forth-on-the-footer-line">Back and forth on the footer line</h2>
<p>I rewrote the footer copy twice. The first version said something like &ldquo;hope this is useful to someone&rdquo; - writing it out, it felt a bit presumptuous, so I changed it to a more self-aware &ldquo;way too niche for anyone, but I made it for myself so here it is.&rdquo; Looking at that again, I decided neither was actually necessary and removed the line entirely. Stating what the thing is felt like enough; why it exists is something a reader can decide for themselves.</p>
<h2 id="two-more-things-shipped-same-day">Two more things shipped, same day</h2>
<p>The blog and this glossary, both shipped the same day, both riding the same subdomain pattern. Whatever comes next, that pattern makes it fast to stand up.</p>
<p>To be continued.</p>
]]></content:encoded></item><item><title>Before Writing a Single Post, I Renamed My GitHub Account</title><link>https://blog.taikiito.com/en/posts/2026-08-22-building-this-blog/</link><pubDate>Sat, 22 Aug 2026 08:00:00 +0800</pubDate><guid>https://blog.taikiito.com/en/posts/2026-08-22-building-this-blog/</guid><description>The decision to start publishing this blog: Hugo + PaperMod, GitHub Actions deploying to GitHub Pages under a custom domain, a private source repo force-pushing built output to a separate public repo, and a GitHub username rename along the way.</description><content:encoded><![CDATA[<p>Last post was about building an RSS reader and tearing it down the same day. Around the same time, I made a bigger call: to start writing down what I&rsquo;d been doing, in public. This blog is the result, and I started it that same day.</p>
<h2 id="why">Why</h2>
<p>The same newsletter that had introduced me to MulmoClaude in the first place also carried some advice from its author: if you want to move toward being an engineer, publish real work on GitHub. The domain setup, self-hosting, and email troubleshooting I&rsquo;d been doing seemed like the kind of thing that could be genuinely useful to someone hitting the same problem via search, so I decided to write it up.</p>
<h2 id="choosing-a-diary-format">Choosing a diary format</h2>
<p>I first considered topic-organized how-to posts, then switched partway through to a chronological diary - writing things in the order they actually happened, mistakes and detours included. It&rsquo;s closer to how it actually went.</p>
<h2 id="picking-the-stack">Picking the stack</h2>
<p>I went with Hugo as the static site generator, mainly because multi-language support (i18n) is built in. Theme: PaperMod. Deploy is GitHub Actions building the site and pushing it to GitHub Pages, with a custom domain (<code>blog.taikiito.com</code>) pointed at it via a Cloudflare CNAME.</p>
<p>I split it into two repos: the actual Hugo source I edit (<code>blog-source</code>) stays private, and a single Actions job builds it and force-pushes only the built static output to a separate public repo (<code>blog</code>). Editing history and drafts never leave the private repo - only the finished output is public, which I like.</p>
<h2 id="renamed-my-username-while-i-was-at-it">Renamed my username while I was at it</h2>
<p>I also renamed my GitHub username at this point. The old one had a string based on my birthdate in it, added without much thought when I first signed up, and I&rsquo;d wanted to change it for a while. My first choice was already taken, so I landed on a <code>-dev</code> suffix instead. With zero public repos at the time, the switching cost was basically nothing.</p>
<p>I also set up a GitHub profile README - creating a repo with the exact same name as your username makes it auto-display on your profile page. Kept it to two lines:</p>
<blockquote>
<p>Tinkering with computers on the side. Hoping it helps someone.</p>
</blockquote>
<h2 id="where-things-stand">Where things stand</h2>
<p>The blog itself and a public GitHub account are both in place now. The actual posts are what comes next, written up after the fact as things happen.</p>
<p>To be continued.</p>
]]></content:encoded></item><item><title>I Built Myself an RSS Reader, and Tore It Down the Same Day</title><link>https://blog.taikiito.com/en/posts/2026-08-21-rss-reader-teardown/</link><pubDate>Fri, 21 Aug 2026 10:00:00 +0800</pubDate><guid>https://blog.taikiito.com/en/posts/2026-08-21-rss-reader-teardown/</guid><description>Self-hosted a Miniflux RSS reader on the same server as MulmoClaude to aggregate news by category, then decided the same day it would just become an overflowing unread inbox, and removed the entire stack.</description><content:encoded><![CDATA[<p>Last post ended with the email sender-reputation issue - I&rsquo;d run out of technical levers to pull there and was just waiting it out. While waiting, I tried something else: a self-hosted RSS reader to keep up with news and blogs in one place.</p>
<h2 id="why-miniflux">Why Miniflux</h2>
<p>A few options were on the table, but I picked Miniflux for being lightweight and easy to self-host. The setup was one Docker Compose file - a Miniflux container plus a PostgreSQL container. Access followed the same pattern as MulmoClaude: a Cloudflare Access authentication gate in front.</p>
<p>The plan was to pull in the Straits Times, the NYT, and a handful of blogs, sorted by category. Twitter/X was out from the start - it dropped free RSS access back in 2023.</p>
<h2 id="what-subscribing-to-feeds-actually-revealed">What subscribing to feeds actually revealed</h2>
<p>Once I actually wired up the feeds, a few things weren&rsquo;t what I expected.</p>
<ul>
<li>The NHK feed had been silently blocked as of August 8th. No explanation given, and it&rsquo;s likely targeting cloud-provider IP ranges as a class, not anything specific to my setup.</li>
<li>The Straits Times and NYT feeds were both alive, but both stop at headline and summary before the paywall.</li>
</ul>
<p>Technically, I got it fully working.</p>
<h2 id="and-decided-to-stop-anyway">And decided to stop anyway</h2>
<p>Once it was working, I paused and thought about it. The idea of aggregating news by category wasn&rsquo;t bad on its own, but running it for real seemed like it would just become an ever-growing pile of unread items - an information-overload sink. The point had been to control how much I read, not to read more.</p>
<p>Same day, I removed the whole thing - the Docker containers, the nginx config, the DNS record, the Cloudflare Access application. Less than 24 hours between building it and tearing it down.</p>
<h2 id="getting-something-working-and-actually-keeping-it-are-different-things">Getting something working and actually keeping it are different things</h2>
<p>This one taught me that getting something technically working is a different question from whether it actually earns a permanent place in my routine. Deciding to build something and then walk away from it isn&rsquo;t necessarily a bad outcome.</p>
<p>To be continued.</p>
]]></content:encoded></item><item><title>With the Domain Working, I Built Myself a Digital Business Card the Same Day</title><link>https://blog.taikiito.com/en/posts/2026-08-19-digital-business-card/</link><pubDate>Wed, 19 Aug 2026 10:00:00 +0800</pubDate><guid>https://blog.taikiito.com/en/posts/2026-08-19-digital-business-card/</guid><description>A same-day side quest after getting the domain working: a static digital business card at a new subdomain, served entirely separately from the main app, with a vCard-download button, a WhatsApp click-to-chat link, and a small run-in with Cloudflare&amp;rsquo;s email obfuscation.</description><content:encoded><![CDATA[<p>Last time, I got <code>taikiito.com</code> working and could reach my own MulmoClaude from a browser. Now that I could carve out subdomains under it, I built one more thing the same day: a digital business card.</p>
<h2 id="one-subdomain-one-static-page">One subdomain, one static page</h2>
<p>I created a new subdomain, <code>card.taikiito.com</code>, and added a second nginx server block on the same Lightsail box. The important part: it&rsquo;s completely separate from the main Node.js app. No <code>proxy_pass</code> - just a single static HTML file being served - so traffic to the card page has zero effect on the main app&rsquo;s load.</p>
<p>The content is lifted straight from the business card I actually use day to day. I kept it down to two buttons:</p>
<ul>
<li>&ldquo;Save Contact&rdquo; - generates a <code>.vcf</code> file on the spot via a JS Blob and opens the phone&rsquo;s add-to-contacts sheet</li>
<li>&ldquo;WhatsApp&rdquo; - a <code>wa.me</code> click-to-chat deep link</li>
</ul>
<p>I originally had a third button for sending an email too, but later cut it down to just an info row with a <code>mailto:</code> link. Fewer buttons turned out to be more usable.</p>
<h2 id="a-small-color-detour">A small color detour</h2>
<p>I&rsquo;d built it in navy from the start, but made a black-and-white version too, just to compare side by side. Navy won, and the black-and-white draft got deleted.</p>
<h2 id="a-small-gotcha">A small gotcha</h2>
<p>Right after launch, previewing the card inside this app&rsquo;s own embedded iframe, the email address showed up as a garbled placeholder string instead of the real thing. My first thought was I&rsquo;d broken something. Turned out to be Cloudflare&rsquo;s &ldquo;Email Address Obfuscation&rdquo; (Scrape Shield) - it automatically encodes email addresses on the page to deter spam scrapers, and a small decoding script is supposed to restore the real address in a normal browser. That decoder just doesn&rsquo;t run inside this app&rsquo;s preview iframe. It&rsquo;s a zone-wide setting, so I left it on and confirmed the real address renders fine in an actual browser tab.</p>
<h2 id="what-a-name-domain-unlocks">What a name-domain unlocks</h2>
<p>The moment my own name became a domain, spinning up small subdomains like this got trivial. A few more of the same pattern would follow.</p>
<p>To be continued.</p>
]]></content:encoded></item><item><title>Getting My Own Domain, and Email On It</title><link>https://blog.taikiito.com/en/posts/2026-08-19-domain-and-email/</link><pubDate>Wed, 19 Aug 2026 09:00:00 +0800</pubDate><guid>https://blog.taikiito.com/en/posts/2026-08-19-domain-and-email/</guid><description>Got a domain with my own name on it, found a backdoor I&amp;rsquo;d accidentally left open while reviewing access settings, and hit silent delivery failures even with SPF/DKIM/DMARC all correctly configured.</description><content:encoded><![CDATA[<p>Last time I wrote about moving to an always-on server. Next: getting my own domain, and email on top of it.</p>
<h2 id="why-bother-with-a-domain">Why bother with a domain</h2>
<p>The server itself was running fine, but the address you&rsquo;d use to reach it wasn&rsquo;t exactly memorable, and I wanted something with my own name in it. I registered <code>taikiito.com</code> via Cloudflare Registrar.</p>
<p>I also learned that a brand-new domain tends to get treated with suspicion for a while by various automated network filters, as &ldquo;uncategorized&rdquo; and unclassified. There&rsquo;s basically no way around this except time - letting the domain accumulate age and a sending history until it earns a trustworthy classification. I registered it now, well before I&rsquo;d actually rely on it, and I&rsquo;m letting that trust build up patiently.</p>
<h2 id="a-backdoor-id-left-open-myself">A backdoor I&rsquo;d left open myself</h2>
<p>While reviewing my access setup, I tried reaching the Lightsail origin&rsquo;s raw IP directly instead of the domain - and it loaded fine.</p>
<p>That meant the authentication gate I&rsquo;d set up via Cloudflare Access, which protects access through the domain, could be completely bypassed by hitting the origin IP directly. The lock I&rsquo;d built for the front door meant nothing if you came in through the back.</p>
<p>The same day, I set up <code>ufw</code> to only allow inbound traffic on ports 80/443 <strong>from Cloudflare&rsquo;s published IP ranges</strong> (<code>https://www.cloudflare.com/ips-v4</code>), denying everything else by default. I made sure to run <code>ufw allow OpenSSH</code> explicitly <em>before</em> enabling ufw (get that order wrong and you can lock yourself out of SSH the moment the firewall activates). After applying it, domain access kept working fine, and direct origin-IP access was cut off entirely.</p>
<h2 id="a-522-error-when-adding-a-second-origin">A 522 error when adding a second origin</h2>
<p>Under this domain, I added a second entry point for MulmoClaude (<code>mulmoclaude.taikiito.com</code>) and a subdomain for a digital business card (<code>card.taikiito.com</code>).</p>
<p>Right after setting up the DNS (A record) and the Cloudflare Access policy for the second entry point, hitting it returned Cloudflare&rsquo;s <strong>522 error (connection timed out to origin)</strong>. The cause: SSL/TLS encryption mode is managed per zone (per domain), not globally. The new <code>taikiito.com</code> zone had defaulted to <strong>Full</strong> mode, so Cloudflare tried HTTPS to the origin on port 443, but nginx there was only listening on port 80. Switching it to <strong>Flexible</strong> mode, matching the existing domain, fixed it immediately.</p>
<h2 id="went-ahead-and-set-up-spfdkimdmarc-for-email-too">Went ahead and set up SPF/DKIM/DMARC for email too</h2>
<p>Since I had the domain, I used iCloud+&rsquo;s <strong>Custom Email Domain</strong> feature to set up <code>me@taikiito.com</code>. No extra subscription - just three DNS records, exactly as instructed:</p>
<ul>
<li><strong>MX</strong>: <code>mx01.mail.icloud.com</code> / <code>mx02.mail.icloud.com</code></li>
<li><strong>SPF</strong> (TXT): <code>v=spf1 include:icloud.com ~all</code></li>
<li><strong>DKIM</strong> (CNAME): <code>sig1._domainkey</code> → <code>sig1.dkim.taikiito.com.at.icloudmailadmin.com</code></li>
</ul>
<p>These prove, through a few different mechanisms, that mail is genuinely coming from my domain and not being spoofed. Simple copy-paste work.</p>
<h2 id="still-didnt-reach-one-particular-inbox">Still didn&rsquo;t reach one particular inbox</h2>
<p>The setup was correct, but a test email to one of my other addresses never arrived. No bounce either - it just silently disappeared.</p>
<p>Just in case, I added <strong>DMARC</strong> too (TXT, <code>v=DMARC1; p=none; rua=mailto:(report address)</code>) and confirmed via an external DNS checker that it had propagated correctly. Still nothing. The cause was a sender-reputation problem that SPF/DKIM/DMARC being perfectly correct simply can&rsquo;t fix - the domain has no sending history yet. Mail servers with strict filtering seem especially sensitive to this.</p>
<p>I&rsquo;d run out of technical levers to pull, so I&rsquo;m letting time solve this one too. Even with SPF/DKIM/DMARC configured flawlessly, a domain&rsquo;s &ldquo;trust&rdquo; has a component that correct technical settings alone can&rsquo;t fix - learned twice now, once for web access and once for email.</p>
<p>To be continued.</p>
]]></content:encoded></item><item><title>After Making MulmoClaude Run 24/7, Its Background Automation Started Looping Forever</title><link>https://blog.taikiito.com/en/posts/2026-08-17-journal-loop-bug/</link><pubDate>Mon, 17 Aug 2026 09:00:00 +0800</pubDate><guid>https://blog.taikiito.com/en/posts/2026-08-17-journal-loop-bug/</guid><description>The day after moving to an always-on server, a background automation task (the journal feature) started endlessly retrying the same sessions. The root cause: a millisecond-precision mismatch between Python and Node.js timestamps.</description><content:encoded><![CDATA[<p>Last time I wrote about moving MulmoClaude to an always-on AWS Lightsail server. The migration itself went fine - but the next day, I noticed something was off with the automation running behind the scenes.</p>
<h2 id="what-was-happening">What was happening</h2>
<p>MulmoClaude has a background feature called <code>journal</code> that auto-generates daily summaries from chat logs. It started taking an abnormally long time every time it ran hourly - some runs took 25 to 90 seconds. It felt like something was retrying over and over, and token usage was climbing noticeably.</p>
<h2 id="tracking-it-down">Tracking it down</h2>
<p>Digging in, I found that a large batch of sessions (625 of them) generated during the migration work (see last post) had been left unprocessed. The hourly job kept trying to process the entire backlog every single run, and kept failing to finish - so the next hour it would try the exact same backlog again. Close to an infinite loop.</p>
<p>Digging further, there was a deeper root cause underneath. The logic that checks whether a session has &ldquo;already been processed&rdquo; compares timestamps, and the Python side and the Node.js side handled milliseconds slightly differently - one truncating, the other rounding. That mismatch meant sessions that should have counted as &ldquo;already processed&rdquo; kept looking &ldquo;unprocessed&rdquo; every single time. A few attempted fixes didn&rsquo;t stick, because none of them addressed the mismatch itself.</p>
<h2 id="stopgap-then-the-real-fix">Stopgap, then the real fix</h2>
<p>First I turned the <code>journal</code> feature off entirely to stop the bleeding. Then I fixed the timestamp handling so both languages treat precision the same way. After that, I confirmed <code>daysSkipped: 0</code> and the hourly check dropped to 2-3 milliseconds.</p>
<p>I also revisited the schedule: <code>journal</code> is now pinned to run once a day (<code>dailyIntervalHours: 24</code>), and the hourly scheduler check itself stays lightweight.</p>
<h2 id="what-i-took-from-this">What I took from this</h2>
<p>&ldquo;Always-on&rdquo; turned out to mean more than just the app staying up - the quiet automation running behind it has to not break either. The most expensive thing was happening somewhere completely invisible from the front end.</p>
<p>To be continued.</p>
]]></content:encoded></item><item><title>Moving to a Server That Never Turns Off</title><link>https://blog.taikiito.com/en/posts/2026-08-16-moving-to-a-server/</link><pubDate>Sun, 16 Aug 2026 09:00:00 +0800</pubDate><guid>https://blog.taikiito.com/en/posts/2026-08-16-moving-to-a-server/</guid><description>Migrating MulmoClaude to AWS Lightsail. A silent CSRF trusted-origin failure, a mystery &amp;lsquo;Network error calling &amp;hellip;&amp;rsquo; traced to a Docker Desktop vs Docker Engine networking difference, and a memory-plan saga.</description><content:encoded><![CDATA[<p>Continuing from before. I&rsquo;d been running MulmoClaude on my local MacBook, which meant it stopped working whenever I closed the lid or took it with me. I increasingly wanted proper web UI access while out and about, away from home. The Telegram bot couldn&rsquo;t handle multiple threads well and its Markdown rendering was weak, so I was starting to feel its limits.</p>
<h2 id="weighing-the-options">Weighing the options</h2>
<p>Leaving my laptop on 24/7 was ruled out on electricity and portability grounds. Connecting directly via SSH from my own machine was ruled out early too - corporate networks commonly block outbound port 22 (SSH). Instead I went with HTTPS over a domain, fronted by an authentication gate (Cloudflare Access). Login is via Google account, plus the app&rsquo;s own shared secret (<code>MULMOCLAUDE_AUTH_TOKEN</code>) as a second layer.</p>
<p>I went back and forth with Sakura Cloud, but chose AWS Lightsail for the lower latency from its Singapore region. Started on the $7/mo bundle (1GB RAM, 2 vCPUs, 40GB SSD), planning to upgrade later if it wasn&rsquo;t enough.</p>
<h2 id="a-silent-failure-the-csrf-trusted-origin-gap">A silent failure: the CSRF trusted-origin gap</h2>
<p>The migration itself went through, but sending a message from the browser just silently failed - no error, no response.</p>
<p>The cause: the app&rsquo;s built-in CSRF guard checks the <code>Origin</code> header, and the new domain hadn&rsquo;t been added to <code>MULMOCLAUDE_TRUSTED_ORIGINS</code> in <code>.env</code>. Adding <code>https://mulmoclaude-taiki.com</code> there and running <code>systemctl restart mulmoclaude.service</code> fixed it. If you&rsquo;re self-hosting MulmoClaude and sends silently fail only from your public domain, check this first.</p>
<h2 id="chasing-down-network-error-calling-">Chasing down &ldquo;Network error calling &hellip;&rdquo;</h2>
<p>A few days later, internal MCP-bridge calls like <code>presentForm</code> and <code>manageCollection</code> started intermittently failing with &ldquo;Network error calling &hellip;: fetch failed.&rdquo;</p>
<p>My first suspicion was memory pressure. <code>/proc/meminfo</code> did show low <code>MemAvailable</code>, with swap mostly used. I installed <code>zram-tools</code> for compressed swap (<code>/dev/zram0</code>) and dropped <code>vm.swappiness</code> to 10 - no change. I even bumped the Lightsail plan from $7/mo (1GB) to $12/mo (2GB) just in case, and the error still reproduced 100% of the time.</p>
<p>The real cause turned up when I read the app&rsquo;s source (<code>server/index.ts</code>): it&rsquo;s designed to <strong>bind only to loopback</strong> - <code>app.listen(port, &quot;127.0.0.1&quot;, ...)</code>. The sandboxed agent reaches the host via <code>host.docker.internal</code>. On <strong>Docker Desktop (Mac)</strong>, that resolves through a hypervisor proxy that does reach loopback. On <strong>Docker Engine (Linux)</strong>, it just resolves to the real IP of the <code>docker0</code> bridge (<code>172.17.0.1</code> in this case) - a genuinely different interface, which a loopback-only bind will never accept a connection from. A latent bug that never surfaced on the Mac, exposed for the first time on Linux.</p>
<p>The fix was a single <code>socat</code> relay bridging the docker-bridge address to loopback:</p>
<pre tabindex="0"><code>ExecStart=/usr/bin/socat TCP-LISTEN:3001,bind=172.17.0.1,fork,reuseaddr TCP:127.0.0.1:3001
</code></pre><p>Registered as a systemd unit, and <code>curl http://host.docker.internal:3001/</code> immediately started returning 200, with <code>presentForm</code>/<code>manageCollection</code> working right away. The memory/zram work wasn&rsquo;t entirely wasted (headroom genuinely improved), but it was never the actual cause of this error.</p>
<h2 id="why-i-ended-back-on-12mo-2gb">Why I ended back on $12/mo (2GB)</h2>
<p>Once the real cause was fixed, I tried downgrading back to $7/mo (1GB) to save money. Lightsail, though, <strong>doesn&rsquo;t support resizing an instance down via snapshot</strong> - it meant rebuilding a 1GB instance from scratch. That rebuilt instance repeatedly hung under load (like building the Docker sandbox image), with SSH and even Lightsail&rsquo;s own browser console becoming unresponsive, more than once.</p>
<p>I concluded 1GB can&rsquo;t reliably sustain this workload (MulmoClaude itself, the Docker sandbox, and the Telegram bridge together) and settled on $12/mo (2GB) as the final plan. Stability won over saving a few dollars.</p>
<h2 id="running-247">Running 24/7</h2>
<p>A few stumbles along the way, but this server has been running around the clock ever since. I can use it anytime, from anywhere, regardless of what state my own laptop is in.</p>
<p>To be continued.</p>
]]></content:encoded></item><item><title>How I Ran Into MulmoClaude, and Got It Actually Usable</title><link>https://blog.taikiito.com/en/posts/2026-07-25-mulmoclaude-genesis/</link><pubDate>Sat, 25 Jul 2026 09:00:00 +0800</pubDate><guid>https://blog.taikiito.com/en/posts/2026-07-25-mulmoclaude-genesis/</guid><description>A regular office worker who tinkers with computers on the side, building a personal AI assistant setup from scratch. Includes the ERR_MODULE_NOT_FOUND stumble.</description><content:encoded><![CDATA[<p>I have a regular day job, and tinker with computers on the side. Not an engineer. This blog is a record of what I did, in the order it happened. If it makes you think &ldquo;I could probably do this too,&rdquo; that&rsquo;s exactly the point.</p>
<p>As a first step, here&rsquo;s how it all started.</p>
<h2 id="how-it-started">How it started</h2>
<p>I came across &ldquo;MulmoClaude&rdquo; through Satoshi Nakajima&rsquo;s newsletter - an open-source project that lets you self-host an AI assistant like ChatGPT or Claude on your own machine instead of as a SaaS, with conversation history and memory persisted as local files you control. I decided to set it up on my MacBook. Environment: Node.js v24.18.0, git 2.50.1, Claude Code CLI v2.1.220.</p>
<h2 id="first-stumble-err_module_not_found">First stumble: <code>ERR_MODULE_NOT_FOUND</code></h2>
<p>There are a few install methods. I first tried the developer route: <code>git clone</code>-ing <code>receptron/mulmoclaude</code>. Running <code>yarn install</code> then <code>yarn dev</code> threw <code>ERR_MODULE_NOT_FOUND</code> and the server wouldn&rsquo;t start. The cause: I was trying to start <code>dev</code> before the package workspace had actually been built. Running <code>yarn build:packages:dev</code> first fixed it immediately. If you hit this exact error, check that build step first.</p>
<p>Once it was working, I found out the officially recommended path is <code>npx mulmoclaude@latest</code> - no build step, no repo to manage. I switched over the same day. The port also changed, from <code>5173</code> (the git-clone/<code>yarn dev</code> setup) to <code>3001</code> (npx). I still run the npx version today. Had I started there, I&rsquo;d have skipped the <code>ERR_MODULE_NOT_FOUND</code> stumble entirely.</p>
<h2 id="getting-it-into-actual-daily-use">Getting it into actual daily use</h2>
<p>Once it was running, I made a few changes to turn it into something usable day to day. First, I wanted access when I wasn&rsquo;t at my computer, so I created a bot (<code>taiki_mulmoclaude_bot</code>) via BotFather and wired it up following the official Telegram Bot API steps. Being able to chat with it on the go turned out to be genuinely useful.</p>
<p>Alongside that, everything I&rsquo;d built up talking to the official Claude app (my work, where I live, my interests, day-to-day notes) got carried over as &ldquo;memory&rdquo; (a system that accumulates as local Markdown files) into this new setup. Picking up from that existing history, instead of starting from zero, made a real difference.</p>
<p>Finally, I didn&rsquo;t want a broken local environment to wipe everything out, so early on I created a private GitHub repository and set up regular automated backups via <code>git push</code>. That habit is still going, and it later gave me the confidence to move everything to a server.</p>
<p>By this point, I had MulmoClaude running on my machine, reachable via Telegram, with its data backed up on GitHub. Next: moving it to an always-on cloud server.</p>
]]></content:encoded></item></channel></rss>