Hello there!

This is a site for stuff I write that doesn’t fit in 280 characters or simple images.

Agent-assisted bilingual subtitles for group watches

I made some agent-assisted subtitle tools for group watches.

The short version is that I wanted one subtitle track with English and Chinese at the same time. Some people read one language faster, some read the other faster, and switching subtitle tracks is not a group activity.

No repo link for this one. The subtitle files are not mine to redistribute, and the interesting part is the workflow anyway.

The group watch problem

The simple version sounds extremely simple:

  1. Take an English subtitle file.
  2. Take a Chinese subtitle file.
  3. Put both lines into one subtitle cue.
  4. Watch the thing.

Unfortunately, subtitle files are not just text. They are text, cue numbers, timestamps, line breaks, release-specific offsets, missing cues, extra signage cues, and sometimes a drift that slowly changes across the whole movie.

The first version I tried was basically a mux. If the English and Chinese files had matching cue numbers, use the English timestamp and stack the text together.

That worked well enough for one case. It was also obviously fragile.

The annoying version

The more interesting case had two subtitle tracks that did not line up with one fixed offset.

If it were just “add 12 seconds to every Chinese cue,” this would not be worth writing about. But the offset changed over time. It started around one value and gradually moved toward another.

So the script got more specific:

  • parse both subtitle files
  • treat the English track as the timing source
  • search for the best Chinese offset in local windows
  • smooth those offsets
  • remap Chinese cues onto the English timeline
  • merge Chinese text into the nearest overlapping English cue
  • preserve Chinese-only cues as their own events
  • write an alignment report for things that looked suspicious

The report was the important part. I did not need the script to pretend everything was perfect. I needed it to tell me where to look.

The useful output was not just the merged .srt. It was also a short review list:

  • low-confidence merges
  • Chinese-only cues
  • English cues with no matching Chinese text
  • local offset anchors

That made the workflow tolerable. Generate, skim the report, spot-check the weird timestamps, adjust the heuristics if needed, and regenerate.

Where agents helped

This is exactly the kind of small, specific tool where agents are useful.

I did not need a general subtitle product. I did not need a GUI. I did not need a library that handles every movie ever made. I needed one script for one group watch, and then a slightly better script for a different subtitle mess.

An agent can help make that cheap enough to bother with.

The first pass can be dumb:

same cue number -> same timestamp -> Chinese line + English line

Then the next pass can be less dumb:

local timing windows -> estimated offset -> remapped cues -> overlap match -> review report

The agent also made it easier to keep the code disposable. I did not have to lovingly design a subtitle framework. I could just ask for the thing I needed, run it, look at the bad spots, and ask for a better report.

If you want to do the same thing, point Codex, Claude Code, or Google Antigravity at this post and your own subtitle files. The post is basically the spec: one timing source, one translated track, local offset search, merged output, and a review report. That is enough for an agent to build the throwaway script for your specific case.

Anyway, it worked for the group watch. We had a very good time, nobody was lost or left behind!

3:35 pm / Subtitles , Python , Agents , Localization , Tooling

codex-status-json

I made a small Rust CLI that prints Codex status as JSON.

The short version is that I wanted Codex conversations to know my account, model, and quota state without opening the interactive TUI and reading /status like a person.

This is mostly useful with Codex goals. I am on the Pro 20x plan, which is the $200/month tier, so sometimes I really do have quota to burn. If I can read the remaining quota first, I can make a goal with an actual target instead of guessing how ambitious I should be.

The missing command

Codex has /status in the interactive CLI. It shows useful stuff: account, model, context, writable roots, and rate limits.

That is fine when I am sitting in the TUI. It is less fine when I want another Codex conversation to ask the same question.

There is an upstream issue asking for this exact kind of thing: a non-interactive JSON/headless replacement for /status. The issue is still open as I write this. There is also codex doctor --json, which is useful, but that is a diagnostic report. It is not the same as “tell me my account/model/quota status right now.”

So I made codex-status-json.

The first version was worse

The first version did what you would expect from a tool born out of impatience: it opened Codex in a PTY, sent /status, waited for the panel to settle, captured the terminal output, stripped ANSI escapes, and parsed the text.

It worked. It was also clearly a bad long-term place to live.

Terminal scraping is annoying because every little UI change can break you. The parser has to care about box drawing, timing, update notices, trust prompts, terminal width, and whatever else the TUI happens to render that day.

Still, it was useful. It gave me JSON like this:

{
  "schema_version": 1,
  "account": {
    "email": "user@example.com",
    "plan": "Pro",
    "raw": "user@example.com (Pro)"
  },
  "model": {
    "name": "gpt-5.5",
    "details": "reasoning high",
    "raw": "gpt-5.5 (reasoning high)"
  },
  "buckets": [
    {
      "name": "default",
      "limits": [
        {
          "kind": "5h",
          "remaining_percent": 99,
          "resets": "14:44",
          "reset_at_unix": 1780350290
        },
        {
          "kind": "weekly",
          "remaining_percent": 70,
          "resets": "08:23 on 7 Jun",
          "reset_at_unix": 1780845815
        }
      ]
    }
  ]
}

That was enough to unblock the thing I wanted.

The less cursed version

A few days later, I switched the default backend to Codex’s app-server.

That is a much better source than scraping the TUI. The tool starts:

codex app-server --listen stdio://

Then it sends JSON-RPC calls for:

  • account/read
  • account/rateLimits/read
  • config/read

That gives it the same kind of account, model, and quota information without pretending a terminal status panel is an API.

Important caveat: Codex app-server is documented, but it is also described as experimental and may change. So this is still not a supported codex status --json. It is just a more sensible workaround than screen scraping.

The PTY backend is still there as a fallback:

codex-status-json --backend pty

But the default is now:

codex-status-json --backend app-server

This is a bridge

I do not expect this to be the forever answer.

Codex’s usage and billing surface is still moving. OpenAI has already introduced things like banked rate-limit resets, so the status shape is not just “two counters and call it done.” There are plans, windows, reset times, secondary buckets, and whatever else comes next.

So I can see why OpenAI might not want to commit to a stable status JSON contract yet. That is fine. I still wanted something local that worked now.

This is the same kind of bridge as a lot of these small tools: use the available pieces, make the annoying gap tolerable, and be happy if the official thing eventually replaces it.

The repo

The project is here:

https://github.com/nelsonjchen/codex-status-command

Install it locally with:

cargo install --path .

The README has the options and limitations. The tests include a captured /status fixture so the text parser can at least fail loudly when Codex changes its display.

I would still rather have an official codex status --json. Until then, this is small, local, and useful enough.

3:03 pm / Codex , Rust , CLI , JSON , Automation

Moving Gmail Send mail as to Cloudflare SMTP

I just finished moving my Gmail “Send mail as” setup completely to Cloudflare Email Service’s native SMTP submission. I can send out emails under my mindflakes.com domain.

The short version is that I had built a small Rust SMTP-to-Cloudflare REST API relay in April. Today I noticed Cloudflare had shipped native SMTP submission.

Part of the appeal here is minimizing vendors. Cloudflare already hosts the domain, handles incoming mail through Email Routing, and now can handle outgoing mail through Email Service too. For a personal domain, that can be cheap or free, and it is a nice alternative to paying for Google Workspace when a personal Gmail account is otherwise fine.

The April hack

In April, I saw Cloudflare’s public beta announcement for Email Service and got excited about using Cloudflare for both inbound and outbound personal-domain mail.

Then I got sad. I wanted Gmail to send mail for one of my domains through Cloudflare Email Service, but I did not see a native SMTP submission path I could just point Gmail at.

Gmail’s Send mail as feature wants a normal authenticated SMTP server. You give it a hostname, a port, a username, and a password. Gmail talks SMTP. It does not want to be handed a REST API endpoint.

[... 668 words]

2:13 pm / Rust , SMTP , Cloudflare , Gmail , Email

Plainize Clip

I made Plainize Clip, a tiny macOS app that cleans the current clipboard and quits.

The short version is that I still wanted the old Plain Clip shape: copy weird text, launch the app, paste something boring.

Not a clipboard manager. Not a menu bar app. Not a background process sitting around in RAM forever. I launch it through Spotlight, it rewrites the pasteboard as plain text, and then it exits.

The annoying clipboard problem

Copying text on macOS is often not just copying text.

Sometimes it brings formatting. Sometimes it brings weird invisible characters. Sometimes it brings smart quotes, hard-wrapped lines, non-breaking spaces, tabs, and whatever else got dragged along from a PDF, web page, chat app, or ticketing system.

Usually I do not want a whole clipboard workflow. I just want the pasteboard to stop being annoying.

So Plainize Clip does the small version:

  1. Read the general pasteboard.
  2. If there is text, clean it.
  3. Write back plain text only.
  4. Quit.

That is the whole normal launch path.

Launch, clean, quit

The important behavior is that Plainize Clip is faceless.

There is no Dock icon to manage after the cleanup. There is no persistent process. There is no history database. If the current pasteboard has text, it cleans it with the saved preferences and replaces the pasteboard contents with text.

[... 608 words]

11:05 pm / Macos , Swift , Clipboard , Appkit , Reverse engineering , Codex

Blocking spammy caller names with VoIP.ms Call Hunting

I made a small Rust VoIP.ms CNAME blocker.

The short version is that I wanted to block a caller by name instead of by phone number.

This sounds like it should be an option in VoIP.ms. For all the features VoIP.ms has, this is not one of them. So I made one for cheap.

The missing filter

VoIP.ms has a pretty capable CallerID Filtering feature. You can filter on specific caller ID numbers, phone book groups, anonymous callers, calls that do not match the North American number format, STIR/SHAKEN attestation level, and wildcard patterns like area-code-ish blocks.

That is all useful, but these asshole spam callers and scammers do not politely stick to one number. They rotate numbers. Blocking one just means the next call comes from another one. What I wanted to block on was the caller ID name.

VoIP.ms has CNAM support too, but as far as I can tell it is for displaying names, not filtering on them. Their blog post on stopping spam calls talks about filtering numbers, area codes, and anonymous callers, while describing CNAM as a way to identify callers before picking up. The Caller ID wiki page describes incoming Caller ID name lookup as an optional per-DID setting that can display a caller name for US and Canadian callers.

[... 718 words]

7:30 pm / Voip , Sip , Rust , Voip ms , Telephony

Unofficial MakerWorld PMM OpenSCAD Reference

I made an unofficial MakerWorld PMM OpenSCAD reference.

The short version is that I wanted Codex to help me make MakerWorld-customizable OpenSCAD models, and I did not want it guessing from old forum posts, UI behavior, and whatever I happened to remember at 1 AM.

This started because I wanted to use agents on my own OpenSCAD projects without re-teaching the same MakerWorld-specific weirdness every time.

The documentation problem

MakerWorld’s Parametric Model Maker is genuinely useful. OpenSCAD plus a web customizer is a great fit for 3D-printable stuff where the interesting part is “same idea, slightly different dimensions.”

If you have not run into it before, OpenSCAD is CAD by writing code. You write variables, modules, and geometry operations, and it turns that into a model. That makes it very good for customizable prints, and very annoying when a target platform has extra rules.

Unfortunately, the OpenSCAD side of PMM is documented in a very internet way. Some of it is in release posts. Some of it is in support replies. Some of it is in the actual web app. Some of it is people trying things and reporting what happened.

There is a Bambu forum thread literally titled Any Documentation on Parametric Model Maker?. The first post asks where to find the requirements for making customizable models. That felt familiar.

For a human, scattered docs are annoying. For a coding agent, they are a great way to get plausible garbage. It might write OpenSCAD that looks fine locally but misses MakerWorld’s actual rules. It might invent a PMM feature because it saw something sort of similar somewhere else.

I do not want an agent inventing // preview[...] as a PMM feature. I want it to know that // color is a thing, // font is a thing, mw_plate_N() is a thing, and default.svg is not just some random example filename.

So I made a reference.

What I collected

The repo is basically a practical map of the PMM OpenSCAD surface:

  • the PMM OpenSCAD API
  • an agent workflow for converting normal OpenSCAD into PMM-ready OpenSCAD
  • gotchas that agents are likely to mess up
  • compatibility rules
  • public source snapshots
  • patterns and checklists
  • a generated docs site

The small examples are the important ones:

accent = "#FF0000"; // color
font_name = "Roboto"; // font

module mw_plate_1() {
    // printable plate here
}

module mw_assembly_view() {
    // preview-only assembly here
}

svg_file = "default.svg";

None of that is hard once you know it. The problem is making sure the agent knows it before it starts “helping.”

I also made a PMM font index. It has 1881 MakerWorld PMM font families and 8267 exact PMM font strings. This got more elaborate than I expected, but fonts are exactly the kind of thing where an agent can confidently choose something that works on your machine and then does not work where the model actually runs.

Why agent-first

This is not meant to replace the official PMM UI or be a polished tutorial for someone’s first customizable model. It is mostly a pile of receipts and instructions for agents.

Point Codex or another coding agent at the repo, then ask it to adapt a .scad file for MakerWorld. The repo gives it rules to retrieve before it starts editing:

  • flatten local includes when PMM probably will not have them
  • do not remove bundled PMM libraries like BOSL2 just because arbitrary local includes are risky
  • use PMM upload defaults like default.svg and default.stl
  • add // color and // font only where those controls are intended
  • treat multi-plate output as a publishing decision, not just a module name
  • cite whether a claim came from an official app endpoint, official release, employee reply, community report, or inference

That last part matters. I do not want a pile of agent-generated confidence. I want the agent to say why it thinks something is true.

The repo

The project is here:

https://github.com/nelsonjchen/unofficial-makerworld-parametric-model-maker-openscad-docs

The generated docs site is here:

https://nelsonjchen.github.io/unofficial-makerworld-parametric-model-maker-openscad-docs/

This is unofficial, not endorsed by Bambu Lab or MakerWorld, and not a license grant for any font, library, model, or web asset. It is practical documentation from public sources and reverse-engineering.

It is also probably incomplete or wrong in places. PMM keeps changing, and some of this stuff only becomes obvious after someone trips over it.

Anyway, this mostly exists so my agents stop making the same MakerWorld OpenSCAD mistakes. If it saves someone else from digging through forum threads while trying to publish a customizable model, great.

8:15 am / Bambu , Makerworld , Openscad , 3d printing , Codex

Cisco Catalyst 9800-CL on GCP: what a fight

I finally wrote up my notes on getting a modern Cisco Catalyst 9800-CL running on Google Cloud Platform.

The short version is that this was much more of a fight than I expected.

Cisco’s public GCP-facing material still nudges people toward Google Cloud Marketplace, but the Marketplace offerings I found were outrageously old: 16.12.1 and 16.12.2s from the 2019-2020 era, plus 17.2.1 and 17.3.5a from the 2020-2021 era.

That is a pretty bad situation if what you actually want is a modern WLC on GCP. The version I actually worked from was 17.15.04d, which Cisco’s software portal lists with a release date of 19-Dec-2025. Cisco’s public guidance still points at a path that appears stuck on software families from years ago, and it was not a good starting point for getting a current controller running.

The current downloadable image path is possible, but it turned out to involve more image surgery and boot-path archaeology than I had hoped for.

I put the actual handoff-style guide here:

That guide covers:

  • where to get the qcow2
  • why Marketplace is not a great baseline right now
  • what actually worked to get the controller reachable
  • how I validated it was really up
  • how to adopt an AP into the supported public-cloud path

One of the main conclusions is that Cisco’s built-in GCP bootstrap path seems to create real first-boot state that the appliance expects, which helps explain why simply editing /varied/iosxe_config.txt offline was not enough on a pristine raw image.

Anyway: what a fight. But at least the notes are in one place now.

1:15 pm / Cisco , Catalyst 9800 , Gcp , Wireless , Homelab

Bambu Lab Store Filament Tracker

I’ve been running a Bambu Lab Store filament tracker at bbltracker.com.

bbltracker dashboard

I recently got into 3D printing. A lot of what I print is practical: last-millimeter adapters, things like robot vacuum ramps around the house, and other tiny fixes that make daily life less annoying.

Bambu Lab released the P2S, their flagship midrange printer, and I picked one up after hearing how polished their ecosystem is. People call them the Apple of 3D printers, and I can see why. The hardware is solid, but the software and ecosystem are really what make it shine.

There is also a bit of a format war around filament. Bambu sells filament with RFID tags that are not easy for third parties to mimic. The RFID flow makes loading filament easy and automatically applies Bambu-tuned profiles. Some people feel those profiles run too fast, but for my functional prints, they have worked great.

Where the frustration started

I like having color options, and color can be functional too. But stock has been rough for a while, even after the holiday season.

That rough stock situation (and the need for a tracker) is really a symptom of how popular Bambu has gotten, especially over the holidays:

Handy chart showing Bambu popularity spike over the holidays

Source: Reddit comment

Then there is the constant bulk-sale mechanic on the Bambu Lab Store: buy 4+, get a strong discount, buy more, save more. In practice, that falls apart when the combinations you want are never all in stock at the same time.

At the time, the store UI also made this harder than it needed to be. Out-of-stock options were not clearly crossed out, so building a cart felt like guesswork.

That made me angry, but in a constructive way. So I built a tracker.

What the tracker does

At a high level, the tracker monitors store inventory over time, keeps historical snapshots, and renders a dashboard so I can quickly answer:

  • What is actually in stock right now?
  • What appears stable versus constantly draining?
  • What was the last major restock spike?
  • Are there ETA hints for restocks?
  • Is this likely to go out of stock soon?

I started with only the US store, then expanded to EU, CA, UK, AU, and Asia coverage.

I also reused a stock-quantity verification trick I had learned while documenting openpilot’s Toyota security key journey.

Building it fast (and keeping it moving)

I used a lot of Google Antigravity while building this. Web scraping is tedious work, and large language models helped a lot with iteration speed and the constant tweaks.

The codebase is not pretty in every corner, but LLMs have made it very workable to maintain and improve continuously.

Later, I added depletion-rate estimation so the tracker can make a rough guess about when something might go out of stock.

Things that happened after launch

After I released it and posted it on /r/BambuLab, a bunch of interesting things happened:

  • Bambu nerfed the original stock-count hole the tracker used.
  • For a while, counts were capped at 200 in the US view, which reduced visibility.
  • Later, that cap moved to 400 in the US store. Maybe my tracker’s utility was actually a factor in that decision? I do see traffic from China, where there is no Bambu Lab Store of the same codebase as far as I can see.
  • Bambu improved their UI and added clearer out-of-stock cross-outs.
  • I added more quality-of-life indicators, including “stable stock,” ETA context, and restock-spike visibility.
  • I added a GoFundMe and a nice patron funded a custom domain and hosting for the tracker.

Backend at a glance

Keeping this online reliably has mostly been a deployment and operations problem:

If you print a lot and buy filament in batches, this has been much better than manually refreshing product pages and hoping for the best.

8:10 pm / Bambu , Filament , Duckdb , Typescript , Cloud run , Cloudflare pages

Surviving a power outage with fiber ONT and PoE setup (Frontier FOX222)

fox222

power supply

For some reason, it’s been a bit hard to find information on how to setup my particular fiber ONT so that it can survive a power outage with a PoE setup.

While of course, one could power a whole house with solar and batteries, I wanted to find a way to keep my fiber ONT and router powered during a power outage without having to invest in a full home battery system.

Costco recently had on sale a small EcoFlow RIVER 3 Plus Portable Power Station, which I installed into my home network setup with the power station being next to my router.

It’s been wonderful as an UPS!

My setup is a bit peculiar as my Frontier FOX222 ONT is a bit far from the power station, but the power station is next to the router and there is an Ethernet cable running from the ONT to the router. The RIVER 3 is also next to the router.

The only way to provide power to the ONT without also having to plumb AC backed by the power station: A PoE injector on the Ethernet cable between the ONT and the router and a splitter at the ONT end to convert the PoE to 16V DC power for the ONT.

[... 764 words]

6:12 pm / Fiber , Ont , Poe , Setup , Power outage , Frontier , Ecoflow , Ups

Talky Pet Watcher, a cat watching AI

Cat Watch

Github Project: talky_pet_watcher

I’m allergic to cats 😢. But a friend asked me to watch their cat. My family was more than willing to help but to be safe, I wanted to also monitor the cat to make sure she didn’t get into any shenanigans that were too much.

With some cheap TP-Link Tapo cameras, I set up a system to watch the cat remotely and ensure she was safe while I could not be next to her. It’s a Telegram bot that also sends me a message whenever the cat is doing something interesting.

It watches multiple cameras and tries to aggregate the data to provide insights on the cat’s behavior.

Here’s the project description:

Talky Pet Watcher is a tool to watch a series of ONVIF webcams and their streams. It captures video clips, uploads them to Google AI for processing, and uses a generative model to create captions and identify relevant clips. The results are then delivered to a Telegram channel. This project is designed to help pet owners keep an eye on their pets and share interesting moments with friends.

This was slapped together in a few days, as I only watched the cat for two weeks.

I was also interested in Google Gemini and how cheap they claimed to be for analyzing video. I figured that this would be a good way to test it out.

What went well:

  • The system was able to capture interesting moments effectively.
  • It was able to concantate multiple camera’s POV to provide interesting views.
  • The cameras were cheap!
  • Putting the results on Telegram was a good way to share the results with the cat’s owner and friends.

What did not go well (and there are many!):

  • I did not implement history, so the system basically reported the same thing over and over again.
  • Getting reliable clips from multiple cameras when motion was detected was iffy due to the TP-Link Tapo camera’s iffy web servers. It would stall and halt a lot.
  • The Google Gemini AI could only analyze video clips at 1FPS. For an agile kitten, this can sometimes make wrong assumptions about what the cat is doing.
  • Implementation wise, bun was very crashy and I had to restart it a lot.
  • The connection to the camera also had to be restarted a lot.

If I had to do it again:

  • Use something that can pull from a NVR by relative timestamps. There was some Rust NVR but it was too complicated for me to set up in the small amount of time I had.
  • Implement a history system and maybe some memory bank system.
  • And so so much more! 😅

6:08 am / Catte , Google gemini , Vision , Telegram

Projects