Programming
-
LangChain and LLM Routers, the Short Version
LangChain is important to know and understand in the age of agents. Also, LLM routing. They’re related but they’re not the same thing, and the distinction matters.
So lets break it down.
LangChain is the Plumbing
Out of the box, an LLM is a text-in, text-out engine. It only knows what it was trained on. That’s it. LangChain is an open-source framework that connects that engine to the outside world.
It gives you standardized tools to build pipelines:
- Models: Interfaces for talking to different LLMs (Gemini, Claude, OpenAI, whatever you’re using)
- Prompts: Templates for dynamically constructing instructions based on user input
- Memory: Letting the LLM remember past turns in a conversation
- Retrieval (RAG): Connecting the LLM to external databases, PDFs, or the internet so it can answer questions about your data
- Agents & Tools: Letting the LLM actually do things, like execute code, run a SQL query, or send an email
You could wire all of this up yourself, but LangChain gives you the standard pieces so you’re not reinventing the plumbing every time.
LLM Routers are the Traffic Controller
A router is an architectural pattern you build on top of that plumbing. Instead of sending every request through the same prompt to the same massive model, a router evaluates the request and directs it to the right destination. Simple concept, big impact.
Three reasons you’d want one:
- Cost: You don’t need a giant, expensive model to answer “Hello!” or look up a basic fact. Send simple queries to a smaller, cheaper model. Save the heavy model for complex reasoning.
- Specialization: Maybe you have one prompt for writing code and another for searching a company HR manual. The router makes sure the query hits the right expert system.
- Speed: Smaller models and direct database lookups are faster. Routing makes your whole application more responsive.
How Routing Actually Works
In LangChain, there are two main approaches:
Logical Routing uses a fast LLM to read the user’s prompt and categorize it. You tell the router LLM something like: “If the user asks about math, output MATH. If they ask about history, output HISTORY.” LangChain then branches to a specialized chain based on that output.
Semantic Routing skips the LLM entirely for the routing decision. It converts the user’s text into a vector (an array of numbers representing the meaning of the text) and compares it to predefined routes to find the closest match. This is significantly faster and cheaper than asking an LLM to make the call.
LangChain provides
RunnableBranchin LCEL (LangChain Expression Language, their declarative syntax for chaining components) for this, basically if/then/else logic for your AI pipelines. Worth digging into if you’re building with LangChain.Routing is what makes AI applications practical at scale. LangChain is one way to build it. They’re complementary, not interchangeable.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
/ AI / Programming / Langchain / LLM
-
What I Learned Building My First Chrome Extension
I built a Chrome extension to navigate Letterboxd movie lists with keyboard shortcuts. Rate, like, watch, next. Here’s what I learned.
The Idea
I going through the Letterboxd lists, but wanted a better way. “Top 250 Films,” curated genre lists, friends' recommendations. The flow becomes tedious: click a movie, rate it, go back to the list, find where you were, click the next one. I wanted to load a list into a queue and step through movies one by one with keyboard shortcuts.
So I did what any reasonable person would do and built a Chrome extension for it.
The Framework Graveyard
The Chrome extension ecosystem has a framework problem. CRXJS, the most popular Vite plugin for extensions, was being archived. Its successor, vite-plugin-web-extension, was deprecated in favor of WXT. WXT is solid but it’s another abstraction layer that could go the same way.
I went with plain Vite and manual configuration. Four separate Vite configs, one per entry point (content script, background worker, popup, manager page). A simple build script that runs them sequentially and copies the manifest. No framework dependency that could die on me.
For the UI I used React and TypeScript. Not because the extension needed React, most of the work is content scripts and background messaging, but the popup and settings page benefit from component structure.
Four Separate Worlds
One thing I learned was a Chrome extension isn’t one app. It’s four separate JavaScript contexts that can’t directly share state:
- Content scripts run on the webpage (letterboxd.com). They can read and modify the DOM but can’t access chrome.tabs or other extension APIs.
- Background service worker runs independently. It handles messaging, storage, and tab navigation. It can die at any time and restart.
- Popup is a tiny React app that opens and closes with the extension icon. It loses all state when closed.
- Extension page (the manager) is a full tab running your own HTML. It persists as long as the tab is open.
They communicate through
chrome.runtime.sendMessageandchrome.storage.local. This is an important architectural challenge you need to be aware of. If you’ve never built an extension before, it could trip you up.Letterboxd’s DOM Is a Moving Target
The existing open-source Letterboxd Shortcuts extension uses selectors like
.ajax-click-action.-liketo click the like button. Those selectors don’t exist anymore. Letterboxd has migrated to React components, and the sidebar buttons (watch, like, watchlist, rate) are loaded asynchronously via CSI (Client Side Includes). They’re not in the initial HTML at all.I had to inspect the actual loaded DOM to find the current selectors:
.watch-link,.like-link,a.action.-watchlist. The rating widget still uses the old.rateitpattern with adata-rate-actionattribute and CSRF token POST.If you’re building an extension that interacts with a third-party site’s DOM, expect the selectors to break. Build your DOM interaction layer as a thin, isolated module so you can update selectors without touching the rest of the codebase.
Service Workers Can’t Use DOMParser
My list scraper used
DOMParserto parse HTML responses. Works fine in tests (jsdom), works fine in content scripts (browser context), fails completely in the background service worker. Service workers don’t have access to DOM APIs.I rewrote the parser to use regex. Less elegant but it works everywhere. If I were doing it again, I’d run the parsing in a content script and message the results back to the background worker.
The Build System Is Simpler Than You Think
I expected the multi-entry-point build to be painful. It wasn’t. Each Vite config is about 20 lines. Content script and background worker build as IIFE (single file, no imports). Popup and manager build as standard React apps. The build script is 30 lines of
execFileSynccalls.One gotcha: asset paths. Vite defaults to absolute paths (
/assets/index.js), but extension popups and pages need relative paths (./assets/index.js). Addingbase: './'to the popup and manager configs fixed it.TDD Was Worth It (For the Right Parts)
The extension has four pure logic modules: rating double-tap behavior, auto-advance detection, queue state operations, and keyboard shortcut matching. These are the core of the extension and they’re completely testable without a browser.
Writing tests first caught edge cases I wouldn’t have thought of. What happens when you press the same rating key on a movie that was already rated in a previous session? What if the queue is empty and someone hits “next”? The tests document these decisions.
For DOM interaction code, the Letterboxd API layer, overlays, CSI-loaded content, unit testing isn’t practical. I tested those manually.
What I’d Do Differently … or might change
Start with the DOM. I built the pure logic first and the DOM interaction last. This meant I didn’t discover the CSI loading issue, the changed selectors, or the DOMParser problem until the end. Next time I’d build a minimal content script first, verify it can interact with the target site, then build the logic on top.
Use fewer Vite configs. Four config files with duplicated path aliases is annoying. A single config with a build mode flag, or a shared config factory function, would be cleaner.
Consider the popup lifecycle earlier. Popups close when you click outside them. Any state they hold is gone. I designed around this (the popup is stateless, it queries the background on every open), but it’s easy to get wrong if you don’t plan for it.
The Result
The extension loads any Letterboxd list into a queue, navigates through movies one by one, and lets me rate/like/watch/watchlist with single keystrokes. Auto-advance moves to the next movie when I’ve completed my actions. A dark-themed manager page shows the full queue and lets me customize every shortcut.
It’s a personal tool right now, so not published to the Chrome Web Store. But it’s made going through movie lists is pretty cool. Sometimes the best software is the kind you build for yourself!
If you’re a developer, I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week. Or you can find me on Mastodon at @[email protected].
-
NotebookLM Is Just RAG With a Nice UI
I’ve been watching AI YouTubers recommend NotebookLM integrations that involve authenticating your Claude instance with some random skill they built. “Download my thing, hook it up, trust me bro.” No details on how it works under the hood. No mention of why piping your credentials through someone else’s code might be a terrible idea. Let’s just gloss over that, I guess.
So here we are. Let me explain what NotebookLM actually is, because once you understand RAG, the magic disappears pretty quickly.
What Is RAG?
RAG stands for Retrieval Augmented Generation. It’s an AI framework that improves LLM accuracy by retrieving data from trusted sources before generating a response.
The LLM provides the reasoning and token generation. RAG provides specific, trusted context. Combining the two gives you general reasoning grounded in your actual data instead of whatever the model has or hallucinated from its training set.
The core pipeline looks like this:
- Take your trusted data (docs, PDFs, YouTube transcripts, whatever)
- Chunk it into pieces
- Create vector embeddings from those chunks
- Store the vectors in a database
- When you ask a question, embed the question into the same vector space
- Find the most similar chunks
- Feed those chunks into the LLM as context alongside your question
That’s it. That’s NotebookLM. Steps 1 through 6 are the retrieval half. Step 7 is where the LLM synthesizes an answer. The nice UI on top doesn’t change what’s happening underneath.
I Accidentally Built Half of It?
I was interested in the semantic embeddings portion of this pipeline and ended up building something I called Semantic Docs. It handles the retrieval half, steps 1 through 6.
You point it at a knowledge base, internal company docs, research papers, whatever you’re interested in. It chunks the content, creates vector embeddings, and stores them in a database. When you search, it creates a new embedding from your query, finds the most similar chunks, and returns those as search results.
The difference between Semantic Docs and NotebookLM is that last step. Semantic Docs gives you the relevant files and passages. It says “here’s where the answers live, go read it.” It doesn’t pipe everything through an LLM to generate a synthesized response. This is a choice, a deliberate choice, not a missing feature.
Why No Official API Is a Problem
NotebookLM doesn’t have an official API. People have reverse-engineered how it works, which means every integration you see is built on undocumented behavior that could break at any time. The AI YouTubers recommending these workflows are essentially saying “trust this unofficial thing with your data and credentials.” That should make you uncomfortable.
If you understand RAG, you can build the parts you actually need. The retrieval half is genuinely useful on its own, and you control the whole pipeline. No third-party authentication. No undocumented APIs. No wondering what happens to your data.
I’ll probably write more about RAG in the future. It’s a good topic and there’s a lot of noise to cut through. For now, just know that the next time someone tells you NotebookLM is magic, it’s really just vector search with a chat interface on top.
If you’re a developer, I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week. Or you can find me on Mastodon at @[email protected].
/ AI / Programming / Rag / Notebooklm
-
The Human's Guide to the Command Line: From Script to System
This is Part 3 of The Human’s Guide to the Command Line. If you missed Part 1 or Part 2, go check those out first, you’ll need the setup from both.
In Part 2, we built a note-taking app. It works. But running it looks like this:
uv run ~/code/note/note.py add "call the dentist"That’s… not great. Real command-line tools don’t make you remember where a Python file lives. You just type the name and it works. By the end of this post, you’ll be able to type:
note add "call the dentist"From anywhere on your computer. Let’s make that happen.
Why Can’t I Just Type
note?When you type a command like
lsorbrew, your shell doesn’t search your entire computer for it. It checks a specific list of directories, one by one, looking for a program with that name. That list is called your PATH.You can see yours right now:
echo $PATHYou’ll get a long string of directories separated by colons. Something like
/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin. When you typebrew, the shell checks each of those directories until it finds a match.Right now,
note.pyis sitting in~/code/note/. That folder isn’t in your PATH, so the shell has no idea it exists. We need to turn our script into something installable, and put it somewhere the shell knows to look.Restructuring Your Project
Python has specific expectations about how a project needs to be organized before it can be installed as a tool. Right now your project looks like this:
note/ pyproject.toml note.pyWe need to reorganize it into a package. Here’s what we’re aiming for:
note/ pyproject.toml src/ note/ __init__.py cli.pyLet’s do it step by step. Open Ghostty and navigate to your project:
cd ~/code/noteCreate the new directories
mkdir -p src/noteThe
-pflag means “create parent directories too.” This creates bothsrc/andnote/inside it in one shot.Move your script
mv note.py src/note/cli.pyYour note-taking code now lives at
src/note/cli.pyinstead ofnote.py.Create the package marker
touch src/note/__init__.pyThis creates an empty file. Python uses
__init__.pyto recognize a folder as a package — it can be completely empty, it just needs to exist.Update pyproject.toml
Open your project in Zed:
zed .Find
pyproject.tomlin the sidebar and open it. You need to add two things. First, tell uv where your code lives by adding this section:[tool.uv] package = trueThen add this section to define your command:
[project.scripts] note = "note.cli:main"That line is doing something specific: it’s saying “when someone types
note, find thenotepackage, go into theclimodule, and call themainfunction.” That’s the samemain()function at the bottom of the code we wrote in Part 2.Your full
pyproject.tomlshould look something like this (the exact version numbers may differ):[project] name = "note" version = "0.1.0" description = "A simple command-line note taker" requires-python = ">=3.12" dependencies = [] [tool.uv] package = true [project.scripts] note = "note.cli:main"Verify the structure
Run this in Ghostty to make sure everything looks right:
ls -R src/You should see:
src/note: __init__.py cli.pyInstalling It For Real
Here’s the moment. From your project directory (
~/code/note), run:uv tool install .That
.means “install the project in the current folder” — same dot from Part 2 when we ranzed .to mean “open this folder.”What just happened? uv created an isolated environment for your tool and dropped a link to it in
~/.local/bin/. That directory is on your PATH (or should be), which means your shell can now find it.Try it:
note listYou should see your notes from Part 2 (or “No notes yet” if you cleared them). Try adding one:
note add "I installed my first CLI tool" note listThat works from anywhere. Open a new terminal tab, navigate to your home directory, your Desktop, wherever…
notejust works now.If you get “command not found”: Run
uv tool update-shelland then restart your terminal. This adds~/.local/binto your PATH in.zshrcso your shell knows where to find tools installed by uv.One thing to know about editing
When you ran
uv tool install ., uv copied your code into its own isolated environment. That means if you go back and editsrc/note/cli.py, your changes won’t show up until you reinstall:uv tool install --force .The
--forceflag tells uv to overwrite the existing installation. During development, you could also useuv tool install -e .(the-eis for “editable”) which keeps a live link to your source code so changes show up immediately without reinstalling.
You went from a Python script you had to run with
uv run note.pyto a real command that works from anywhere on your machine.Welcome to your first CLI!
/ Programming / Python / Cli
-
The revenue design company | Orb
Design, execute, and operate revenue with usage-based billing. Orb helps modern software companies adapt pricing as products, usage, and costs evolve.
-
Lisette is a New Rust-to-Go Language, So I Built It a Test Library
This morning I dove into a new programming language called Lisette. I saw it from @lmika and had to take a look. It gives you Rust-like syntax but compiles down to Go, and you can import from the Go standard library directly.
It’s early in development, so a lot of things don’t exist yet. They have a roadmap posted with plans for third-party package support, a test runner, bitwise operators, and configurable diagnostics.
So Naturally, I Built a Test Library
Anyone who reads my blog knows I care a lot about testing. So when I saw “implement a test runner” sitting on the roadmap, I did what any reasonable person would do on a Monday morning. I built a testing library for Lisette called LisUnit.
I wanted something that felt familiar if you’ve used Jest or PHPUnit. Test cases are closures that return a result, and assertions work the same way. Here’s what it looks like:
lisunit.Suite.new("math") .case("add produces sum", || { lisunit.assert_eq_int(add(2, 3), 5)? Ok(()) }) .case("add is commutative", || { lisunit.assert_eq_int(add(2, 3), add(3, 2))? Ok(()) }) .run()Define a suite, chain your test cases, run it.
Why Bother?
I don’t know exactly what direction the Lisette team is headed with their own test runner, so this is just a prototype. Building a test library turns out to fun way to try out a new language because you end up touching a lot of language constucts?
I’ll probably keep poking at it as Lisette evolves. Happy Monday.
/ Programming / Golang / Open-source / Testing / Rust
-
Lisette — Rust syntax, Go runtime
Little language inspired by Rust that compiles to Go.
/ Programming / Golang / links / open source / Rust / compiler
-
Hiding Poems Inside Images
I built a tool that hides poems inside images. Not as metadata, not as a watermark. The actual text of the poem drives the visual pattern, and you can reconstruct the poem perfectly from the image alone.
How It Works
You give it a poem. It analyzes the syllable count, rhyme scheme, and stress patterns. Then it generates a visual pattern where those poetic features drive the aesthetics: spiral width, dot placement, block size, line weight.
The text itself is encoded into the pattern in a variety of ways. The pattern isn’t just inspired by the poem, it IS the poem. Run the decoder on the image and you get back the original text, character for character, including whitespace and punctuation.
Seven Renderers, Two Approaches
There are seven different visual styles, split into two categories.
The steganographic renderers (geometric, concentric, waveform) hide text invisibly in pixel color channels using LSB encoding. The visual pattern is purely decorative. This is a well-known technique. nothing new there.
I wanted to build something different, so I focused on visual encoding patterns. The encoding is the art, and the art is the encoding. Everything about how the image is constructed follows repeatable algorithms so it can be decoded back:
- Nautilus draws a golden spiral where line width carries the data
- Fibonacci uses a sunflower phyllotaxis dot pattern
- Mosaic creates an adaptive block grid
- Dotline connects dots with varying line weight
Each has different capacity. The nautilus spiral can hold about 2,200 characters, enough for a full Whitman poem. A fibonacci pattern holds about 1,600. Even the smallest renderer handles a haiku easily.
Right now I’m just having fun building different visuals, images that encode and decode. Eventually I might build an API around it, but for now it’s a side project…
A Basho’s haiku is here:

/ Art / Programming / Poetry / Side-projects
-
The Human's Guide to the Command Line: Your First CLI App
This is Part 2 of The Human’s Guide to the Command Line. If you missed Part 1, go check that out first — we got Homebrew and Ghostty set up, which you’ll need for everything here.
Now we’re going to do something that feels like a big leap: we’re going to write a real command-line application. A small one, but a real one. Before we get there, though, we need two things — a programming language and a code editor.
Picking a Language
You can write CLI apps in a lot of languages. JavaScript, Go, Rust — they all work. But if you’re new to programming, I think Python is the right starting point. It reads almost like English, it’s everywhere, and you won’t spend your first hour fighting a compiler.
Python is what we’ll use for this series. That said, Python is notoriously tricky to set up locally; there are version managers, virtual environments, and a whole ecosystem of packaging tools that can make your head spin.
First things first, we need a code editor (an IDE).
A Code Editor: Zed
Before we write any code, you need somewhere to write it. We’re going to install Zed — it’s fast, clean, and won’t overwhelm you with buttons.
brew install --cask zedOpen Zed from your Applications folder once it finishes.
Install the
zedCommandHere’s the move that ties everything together. Open Zed’s command palette with Cmd + Shift + P, type
cli install, and hit Enter.Now you can open any folder in Zed directly from Ghostty:
zed . # open the current folder zed note.py # open a specific fileThat
.means “right here” — you’ll see it everywhere in the terminal and it always means the same thing: the folder you’re currently in.Set Up Your Project
Time to create a home for your code. Back in Ghostty:
mkdir ~/code mkdir ~/code/note cd ~/code/noteThree commands. You just created a
codefolder in your home directory, created anotefolder inside it, and stepped into it. Now open it in Zed:zed .Zed opens with your empty project folder in the sidebar on the left. This is where we’ll create our script.
Building a Note Taker
We’re going to build a tiny app called
note. It does three things: add a note, list your notes, and clear them all. That’s it. No database, no accounts, no cloud. Just you and a JSON file.Create your script in Zed’s sidebar by clicking the New File icon, name it
note.py, and paste in the code from the next section.import argparse import json import sys from datetime import datetime from pathlib import Path # Where your notes live — ~/notes.json NOTES_FILE = Path.home() / "notes.json" def load_notes(): if not NOTES_FILE.exists(): return [] with open(NOTES_FILE) as f: return json.load(f) def save_notes(notes): with open(NOTES_FILE, "w") as f: json.dump(notes, f, indent=2) def cmd_add(args): notes = load_notes() note = { "text": args.text, "added": datetime.now().strftime("%Y-%m-%d %H:%M"), } notes.append(note) save_notes(notes) print(f"Added: {args.text}") def cmd_list(args): notes = load_notes() if not notes: print('No notes yet. Add one with: note add "your note"') return for i, note in enumerate(notes, start=1): print(f"{i}. {note['text']} ({note['added']})") def cmd_clear(args): answer = input("Delete all notes? This can't be undone. (y/n): ") if answer.lower() == "y": save_notes([]) print("All notes cleared.") else: print("Cancelled.") def main(): parser = argparse.ArgumentParser( prog="note", description="A simple command-line note taker.", ) subparsers = parser.add_subparsers(dest="command") # note add "some text" add_parser = subparsers.add_parser("add", help="Add a new note") add_parser.add_argument("text", help="The note to save") # note list subparsers.add_parser("list", help="Show all notes") # note clear subparsers.add_parser("clear", help="Delete all notes") args = parser.parse_args() if args.command == "add": cmd_add(args) elif args.command == "list": cmd_list(args) elif args.command == "clear": cmd_clear(args) else: parser.print_help() if __name__ == "__main__": main()Part 3: Python Without the Mess (uv)
Your Mac already has Python on it, but don’t use it. That version belongs to macOS which it uses internally, and if you start installing things into it you can cause yourself real headaches. We’re going to use our own Python that’s completely separate.
The tool for this is uv. It manages Python for you and keeps everything isolated so you never touch the system version.
brew install uvThat’s the whole install. Verify it worked:
uv --versionYou should see a version number printed back. If you do, you’re good.
Create Your Project
Inside your
~/code/notefolder, run:uv initThis sets up a proper Python project in the current folder. You’ll see a few new files appear in Zed’s sidebar, but don’t worry. If you see a
hello.py, which uv creates as a starter file, you can delete it.Running Your Script
Once you have code in
note.py, run it like this:uv run note.py add "call the dentist" uv run note.py list uv run note.py clearuv runhandles everything — it picks the right Python version, keeps it sandboxed to this project, and runs your script. You never typepython3directly, you never activate a virtual environment, you never install packages globally. It just works.Try it now:
uv run note.py listIf you see
No notes yet. Add one with: note add "your note"but otherwise, everything should be working.
Try this: Once your script is running, try
uv run note.py --help. You’ll get a clean description of every command, automatically. That’s one of the thingsargparsegives you for free./ Programming / Python / Tutorial / Command-line
-
The Human's Guide to the Command Line (macOS Edition)
If you’ve ever stared at a terminal window and felt like you were looking at the cockpit of an airplane, this post is for you. The command line doesn’t have to be scary, and I’m going to walk you through setting up a genuinely great terminal experience on your Mac, from scratch.
This is Part 1 of a Series. I will update the links here as I publish the other articles.
The Mental Model
Before we type anything, here’s the thing you need to understand: the terminal is just a text-based version of Finder.
When you see a folder in Finder, you double-click to open it. In the terminal, you type
cd(change directory) to enter it. You can run applications from Finder, and you can run applications from the terminal. It’s just a different way to do things you already understand.Once that clicks, everything else falls into place.
Part 1: The Foundation (Homebrew)
On a Mac, you install apps from the App Store. On the command line, we use Homebrew — it’s basically the App Store for the terminal. It’s a package manager that makes installing tools painless.
Let’s start by opening the terminal. Hit Command + Space, type “Terminal”, and hit Enter. Then paste this:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"It’ll ask for your Mac password. When you type it, nothing will appear on the screen. This is normal, it’s a security feature, not a bug. Just type your password and hit Enter.
Homebrew needs admin access because it creates system-level folders (usually
/opt/homebrew) and changes their ownership from the system account to your user account.Once it finishes, verify it’s working:
which brewIf that doesn’t return a path, run these two lines to add Homebrew to your shell:
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile eval "$(/opt/homebrew/bin/brew shellenv)"Part 2: A Better Terminal (Ghostty)
Now that we have Homebrew, the first thing we’re going to install is a better terminal. The built-in Terminal app works, but Ghostty is faster, more configurable, and just nicer to use.
brew install --cask ghosttyOnce it finishes, find Ghostty in your Applications folder and open it. You can close the old Terminal app forever, congratulations, you’ve graduated.
Part 3: Making It Beautiful (Nerd Fonts & Starship)
Standard fonts don’t have icons for folders, git branches, or cloud status. We need a Nerd Font so the terminal can speak in pictures.
Install the Font
brew install --cask font-fira-code-nerd-fontThen go to Ghostty’s settings (
Cmd + ,), find the Text section, and set your font toFiraCode Nerd Font.Install Starship
Starship is the paint job that turns a boring
$prompt into something colorful and actually helpful. It shows you what folder you’re in, what git branch you’re on, and more…brew install starshipWhile we’re at it, let’s install two plugins that make typing in the terminal way more pleasant. One whispers suggestions based on your command history, and the other color-codes your commands so you can spot typos before you hit Enter.
brew install zsh-autosuggestions zsh-syntax-highlightingWire It All Up
We need to tell your Mac to turn these features on every time you open a terminal. Copy and paste this entire block into Ghostty:
# Add Starship grep -qq 'starship init zsh' ~/.zshrc || echo 'eval "$(starship init zsh)"' >> ~/.zshrc # Add Auto-suggestions grep -qq 'zsh-autosuggestions.zsh' ~/.zshrc || echo "source $(brew --prefix)/share/zsh-autosuggestions/zsh-autosuggestions.zsh" >> ~/.zshrc # Add Syntax Highlighting grep -qq 'zsh-syntax-highlighting.zsh' ~/.zshrc || echo "source $(brew --prefix)/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh" >> ~/.zshrc # Refresh your terminal source ~/.zshrcEach line checks if the setting already exists before adding it, so it’s safe to run more than once. When it finishes, your prompt should look completely different. Welcome to your CLI future.
Part 4: Basic Survival Commands
Now that your terminal looks proper, here’s how you actually move around.
Command Human Translation pwd“Where am I right now?” ls“Show me everything in this folder.” cd [folder]“Go inside this folder.” cd ..“Go back one folder.” clear“Wipe the screen and start fresh.” touch [file.txt]“Create a new blank file.” That’s about 90% of what you need to navigate around.
Two Golden Rules
1. Tab is your best friend. Type
cd Docand hit Tab — the terminal will finish the wordDocumentsfor you. This works for files, folders, and even commands. If there are multiple matches, hit Tab twice to see all the options.2. Ctrl + C is the panic button. If the terminal is doing something you didn’t expect, or a command is running and won’t stop, hold Ctrl and press C. It’s the emergency brake, and it’s always there for you.
A good next step: try navigating to your Desktop using only
cdandls. Find a file there, maybe create one withtouch, and then look at it withls. Once you can do that comfortably, you’ve officially mastered the basics./ Programming / Macos / Tutorial / Command-line
-
I Benchmarked JSON Parsing in Bun, Node, Rust, and Go
I’m just going to start posting about JSON everyday. Well ok, maybe not every day, but for the next few days at least. Later this week I’ve committed to writing a guide on getting started with CLIs for non-programmers, so stay tuned for that.
This morning I benchmarked JSON parsing across four runtimes: Bun, Node, Rust, and Go.
The Results
- Bun is the overall winner on large files — 307-354 MB/s, beating even Rust’s serde_json for untyped parsing
- Rust wins on small/nested data (225 MB/s small, 327 MB/s nested) due to low overhead
- Node is close behind Bun — V8’s JSON.parse is very optimized
- Go is ~3x slower than the JS runtimes on large payloads (encoding/json is notoriously slow)
- Memory: Bun reports 0 delta (likely GC reclaims before measurement), Rust’s tracking allocator shows the true heap cost (73-96MB), Go uses 52-65MB
Rust’s numbers were the most honest here since the tracking allocator catches everything. We should take Bun result with grain of salt because benchmarking memory in GC’d languages is tricky.
The json parser in v8 in node is the exact same as what is in Chrome…
Here’s the full test results if you want to dig into the numbers yourself.
More JSON content coming soon. You’ve been warned.
/ Programming / Bun / Node / Json / Benchmarks / Rust / Go
-
Is There Something Better Than JSON?
Have you ever looked at a JSON file and thought, “There has to be something better than this”? I have.
JSON has served us well. It works with everything, and it’s human readable. It’s a decent default, don’t get me wrong, but the more you use it, you’ll find its limitations to be quite painful. So before we answer the question of whether there’s anything better, we should describe what’s actually wrong with JSON.
The Problems with JSON
First, there’s no type system. No datetimes, no real integers, no structs, no unions, no tuples. If you need types, and you almost always do, you’re on your own.
Second, JSON is simple, which sounds like a feature until you try to store anything complicated in it. You end up inventing your own schema, and the schema tooling out there (JSON Schema, etc.) gets verbose fast. Because the spec is so loose, validation can be inconsistent across implementations.
There’s more: fields can be reordered, you have to receive the entire document before you can start verifying it, and there are no comments. You can’t leave a note for the next person explaining why a config value is set a certain way. That’s a real problem for anything that lives in version control.
The Machine-Readable Alternatives
Now, there are plenty of binary serialization formats that solve some of these issues. Protobuf, Cap’n Proto, CBOR, MessagePack, BSON. They’re all interesting and have their place. But they’re machine readable, not human readable. You can’t just open one up in your editor and make sense of it. So let’s set those aside.
The question I’m more interested in is: is there something better than JSON that you can still read and edit as a text file?
It turns out there are two solid options.
Dhall
Dhall is a programmable configuration language. Think of it as JSON with all the things you wish JSON had: functions, types, and imports. You can convert JSON to Dhall and back, and it’s just a text file you can open in any editor. The name comes from a character in an old video game, and the language itself is interesting enough that it’s worth your time to explore.
CUE
CUE stands for Configure, Unify, and Execute. It’s similar to Dhall in that it fills the gaps JSON leaves behind, like types, validation, and constraints, while staying human readable. Where CUE really pulls ahead is in its feature set. You can import Protobuf definitions, generate JSON Schema, validate existing configs, and a lot more. In terms of raw capabilities, CUE has more going on than Dhall.
JSON isn’t going anywhere. But if you’re looking for something interesting to explore, check out both of these. They make great fun little side projects.
/ DevOps / Programming / Json / Configuration
-
The Death of Clever Code
One positive product of working with Agentic tools is they rarely suggest clever code. No arcane one-liners, no “look how smart I am” abstractions. And, well, I’m here for it.
Before we continue it helps to understand a bit about how LLMs work. These models are optimized for pattern recognition. They’ve been trained on massive amounts of code and learned what patterns appear most frequently.
Clever code, by definition, is bespoke. It’s the unusual pattern, the one-off trick. There just isn’t enough training data for cleverness. The AI gravitates toward the common, readable solution instead.
Let me give you an example.
Show Me the Code
Here’s a nested ternary:
const result = a > b ? (c > d ? 'high' : 'mid') : (e > f ? 'low' : 'none');I’d be impressed if you could explain that correctly on your first try. What happens when there’s a bug in one of those conditions? Good luck debugging that.
Now here’s the same logic:
let result; if (a > b) { if (c > d) { result = 'high'; } else { result = 'mid'; } } else { if (e > f) { result = 'low'; } else { result = 'none'; } }A lot easier, right? If it’s easy to read, it’s easy to maintain. The AI tooling doesn’t struggle to read either version, but you might, and when there is a bug, explaining exactly what needs to change becomes the hard part.
Actually wait. It turns out, not all complexity is created equal.
Two Kinds of Complexity
Essential complexity is the complexity of the problem itself. If you’re building a mortgage calculator or doing tax calculations, there’s inherent complexity in understanding the domain. You can’t simplify that away, and you shouldn’t try.
Accidental complexity is the stuff you introduce. The nested ternary instead of the if/else. Five layers of abstraction for the sake of abstraction that only runs in a specific edge case. Generic utility functions where you’ve tried to cover every possible scenario, but realistically you only need two or three cases.
Ok but what about abstraction, since abstraction is where accidental complexity loves to hide?
Good Abstraction vs. Bad Abstraction
Abstraction shows up everywhere in programming, but let’s think about it in two flavors.
Good abstraction hides details the caller doesn’t need to care about. The interface clearly communicates what it does. Think
array.sort(), you look at it and immediately know what’s happening. Those dang arrays getting some sort of sorted. You know exactly what it does without caring about the implementation.Bad abstraction hides details you do need to understand in order to use it correctly. Think of a
processData()method that’s doing six different things with an internal state that’s nearly impossible to test. And splitting it intoprocessData1()throughprocessData6()doesn’t help either. That’s just moving the vegetables around on your plate which doesn’t mean you’ve actually finished dinner.AI Signals
So why does any of this matter for working with AI coding tools?
Because if the agents keep getting your code wrong, if they consistently misunderstand what a function does or there are incorrect modifications, that’s a signal.
It’s telling you that your code has some flavor of cleverness that makes it hard to reason about. Not just for the AI, but for your team, and for you six months from now.
The goal is to code where the complexity comes from the problem, not from the solution. The AI struggling with your code is like a canary in the coal mine for maintainability.
/ AI / Programming / Code-quality
-
From SUnit to Vitest: A Brief History of JavaScript Testing
I care a lot about testing. I don’t know if that’s obvious yet, but hopefully it’s obvious. I wanted to trace the lineage of the testing tools we use today, because I think understanding where they came from helps you appreciate why things work the way they do.
Where It All Started
Automated testing as we know it really started with SUnit for the Smalltalk language. Kent Beck created it back in 1989, and it established the patterns that every test framework still borrows from today.
In 1997, Kent Beck and Erich Gamma ported those ideas to Java and created JUnit. JUnit was, or is, incredibly influential to pretty much every unit testing framework you’ve ever used. The test runner, assertions, setup and teardown, all of that traces back to JUnit.
But I’m going to focus on the JavaScript side of things here.
Jest: The Facebook Era
Jest was originally created at Facebook in 2011 as part of a major platform rewrite. It became the dominant testing framework for React and Node.js codebases, and in 2022, Facebook released it to the OpenJS Foundation.
Jest works well, but it carries some baggage. It requires a transpilation pipeline, something that was common a decade ago but feels burdensome now. If you want to use ESM modules, there’s an extra step involved. It’s adds friction.
So what else is there?
Vitest: The Modern Alternative
Vitest is a modern alternative, built on top of Vite. It supports ESM modules and TypeScript out of the box; so no transpilation step needed. And because Vite has HMR (hot module replacement), the watch mode for rerunning tests is very fast.
Vitest was initially created by Anthony Fu and the company behind Vue.js. The initial commit was in December 2021, so it’s a relatively recent project. They’ve made incredible progress since then.
Vitest uses Vite under the hood. So lets look at that briefly.
Why Vite Is So Fast
Vite’s speed comes from esbuild, which is written in Go. It compiles directly to native machine code, so it bypasses the JavaScript engine’s overhead entirely. It can transform TypeScript significantly faster because it doesn’t need to go through the JS engine. And because it’s Go, it’s multithreaded.
But things are changing. In Vite 8, the bundler is moving from esbuild to Rolldown. This is new tool written in Rust that combines the best of esbuild and Rollup.
Why? Currently, Vite uses esbuild during development but switches to Rollup for production builds. Two different tools for two different use cases. Rolldown unifies both into a single tool that handles dev and production.
What Did We Learn?
Hopefully something! How about a mini review to nail it home:
- 1989: SUnit (Smalltalk) — Kent Beck starts it all
- 1997: JUnit (Java) — the template everything else follows
- 2011: Jest — Facebook’s testing framework, now under OpenJS Foundation
- 2021: Vitest — modern, fast, ESM-native testing built on Vite
- Coming soon: Rolldown replaces esbuild + Rollup in Vite 8
That’s all for now!
/ Programming / Testing / javascript
-
Garbage Collection: How Python, JavaScript, and Go Clean Up After Themselves
It’s Garbage day for me IRL and I wanted to learn more about garbage collection in programming. So guess what? Now you get to learn more about it too.
We’re going to focus on three languages I work with mostly, Python, JavaScript, and Go. We will skip the rest for now.
Python: Reference Counting
Python’s approach is the most straightforward of the three. Every object keeps track of how many things are pointing to it. When that count drops to zero, the object gets cleaned up immediately. Simple.
There’s a catch, though. If two objects reference each other but nothing else references either of them, the count never hits zero. That’s a reference cycle, and Python handles it with a secondary cycle detector that periodically scans for these orphaned clusters. But for the vast majority of objects, reference counting does the job without any fancy algorithms.
JavaScript (V8): Generational Garbage Collection
Most JavaScript you encounter is running on V8… so Chrome based browsers, Node and Deno. V8 uses a generational strategy based on a simple observation: most objects die young.
V8 splits memory into two main areas:
- The Nursery (Young Generation): New objects land here. This space is split into two halves and uses a scavenger algorithm. It’s fast because it only deals with short-lived variables — and most variables are short-lived.
- Old Space (Old Generation): No, not the deodorant company. Objects that survive a couple of scavenger rounds get promoted here. Old space uses a mark-and-sweep algorithm, which is slower but handles long-lived objects more efficiently.
First it asks, “How long has this object been around?” New stuff gets the quick treatment, and anything that sticks around gets put out to the farm, to be delt where time is less of a premium. It’s a smart tradeoff between speed and memory efficiency.
Go: Tricolor Mark-and-Sweep
Go’s garbage collector also uses mark-and-sweep, but with a twist called tricolor marking. Here’s how it works:
- White objects: These might be garbage but haven’t been checked yet. Everything starts as white.
- Gray objects: The collector has reached these, but it hasn’t scanned their children (the things they reference) yet.
- Black objects: These are confirmed alive — the collector has scanned them and all their references.
The collector starts from known root objects, marks them gray, then works through the gray set — scanning each object’s references and marking them gray too, while the scanned object itself turns black. When there are no more gray objects, anything still white is unreachable and gets cleaned up.
Go’s approach is notable because it runs concurrently. This helps with latency while the GC is running.
Garbage, Collected
Each approach reflects the language’s priorities:
- Python optimizes for simplicity and predictability. Objects get cleaned up correctly when they’re no longer needed
- JavaScript optimizes for speed in interactive applications. Quick cleanup for short-lived objects, thorough cleanup for the rest
- Go optimizes for low latency. Concurrent collection is great for server side processes
References
- Design of CPython’s Garbage Collector — Python Developer’s Guide deep dive into reference counting and cycle detection
- Orinoco: Young Generation Garbage Collection — V8’s parallel scavenger and generational GC design
- A Guide to the Go Garbage Collector — Official Go documentation on the concurrent tricolor collector
/ Programming / Golang / Python / javascript / Garbage-collection
-
How Smart People Are Using Claude Code Skills to Automate Anything
🚀 Build agentic systems that run your business: skool.com/scrapes Don’t miss the next build - www.youtube.com/@simonscr…
/ Programming / Claude / links / code
-
Everything Is Eventually a Database Problem
I think there’s a saying that goes something like “code is ephemeral, but data is forever.” That’s never been more true than right now. Code is easier than ever to create, anyone can spin up a working app with an AI agent and minimal experience. But your data structure? That’s the thing that sticks around and haunts you.
Data modeling is one of those topics that doesn’t get enough attention, especially given how critical it is. You need to understand how your data is stored, how to structure it, and what tradeoffs you’re making in how you access it. Get it right early, and your code stays elegant and straightforward. Get it wrong, and your codebase becomes a forever series of workarounds…
Microservices Won’t Save You
For teams moving from a monolith to microservices, if the data stays tightly coupled, you don’t really have microservices; you have a distributed monolith with extra network hops.
Yes, data can be coupled just like code can be coupled. If all your different services are still hitting the same database with the same schema, you have a problem. You need separate data structures for your services, not a monolithic architecture hiding behind a microservices facade.
The Caching Trap
So what happens when you have a lot of data and your queries get slow? You’ve done all the easy stuff; optimized queries, added indexes, followed best practices. But things are still slow.
Every senior engineer’s first instinct is the same: “Let’s add Redis in front of it.” Or “more read replicas.” And sure, that works, but you have just added complexity and now you have to deal with cache invalidation.
What happens when you have stale data? How do you recache current data, and when does that happen?
Are you caching on the browser side too? Understanding where data can be cached and how to invalidate it is another genuinely difficult problem to solve. You’re just trading one set of problems for a different set of problems.
You Can’t Predict Every Future Question
If you’re selling things on the internet, chances are, you will care about event sourcing at some point. A lot of interesting business problems don’t care about the current state of a user, they care about the intent and history. So how you store intent and history is probably different from your ACID-compliant Postgres table that you’ve worked hard to normalize.
You can get your data structure perfect for displaying products and processing sales, then run into a completely new set of requirements that changes everything about how your data needs to be structured.
It’s genuinely hard to foresee all the potential questions you’ll need to answer in the future.
Why This Matters Now
Everything you do on a computer stores data somewhere, it’s just a matter of persistence.
Which is why; everything software-related is eventually a database problem.
Data modeling isn’t glamorous, but getting it right is the difference between a system that scales gracefully and one that fights you every step of the way.
/ Programming / Databases / Architecture
-
Your Context Window Is a Budget — Here's How to Stop Blowing It
If you’re using agentic coding tools like Claude Code, there’s one thing you should know by now: your context window is a budget, and everything you do spends it.
I’ve been thinking about how to manage the budget. As we are learning how to use sub-agents, MCP servers, and all these powerful capabilities we haven’t been thinking enough about the cost of using them. Certainly the dollars and cents matters too if you are using API access, but the raw token budget you burn through in a single session impacts us all regardless. Once it’s gone, compaction kicks in, and it’s kind of a crapshoot on whether it knows how to pick up where we left off on the new session.
Before we talk about what you can do about it, let’s talk about where your tokens go, or primarily are used.
Why Sub-Agents Are Worth It (But Not Free)
Sub-agents are one of the best things to have in agentic coding. The whole idea is that work happens in a separate context window, leaving your primary session clean for orchestration and planning. You stay focused on what needs to change while the sub-agent figures out how.
Sub-agents still burn through your session limits faster than you might expect. There are actually two limits at play here:
- the context window of your main discussion
- the session-level caps on how many exchanges you can have in a given time period.
Sub-agents hit both. They’re still absolutely worth using and working without them isn’t an option, but you need to be aware of the cost.
The MCP Server Problem
MCP servers are another area where things get interesting. They’re genuinely useful for giving agentic tools quick access to external services and data. But if you’ve loaded up a dozen or two of them? You’re paying a tax at the start of every session just to load their metadata and tool definitions. That’s tokens spent before you’ve even asked your first question.
My suspicion, and I haven’t formally benchmarked this, is that we’re headed toward a world where you swap between groups of MCP servers depending on the task at hand. You load the file system tools when you’re coding, the database tools when you’re migrating, and the deployment tools when you’re shipping. Not all of them, all the time.
There’s likley more subtle problems too. When you have overlapping MCP servers that can accomplish similar things, the agent could get confused about which tool to call. It might head down the wrong path, try something that doesn’t work, backtrack, and try something else. Every one of those steps is spending your token budget on nothing productive.
The Usual Suspects
Beyond sub-agents and MCP servers, there are the classic context window killers:
- Web searches that pull back pages of irrelevant results
- Log dumps that flood your context with thousands of lines
- Raw command output that’s 95% noise
- Large file reads when you only needed a few lines
The pattern is the same every time: you need a small slice of data, but the whole thing gets loaded into your context window. You’re paying full price for information you’ll never use.
And here’s the frustrating part — you don’t know what the relevant data is until after you’ve loaded it. It’s a classic catch-22.
Enter Context Mode
Somebody (Mert Köseoğlu - mksglu) built a really clever solution to this problem. It’s available as a Claude Code plugin called context-mode. The core idea is simple: keep raw data out of your context window.
Instead of dumping command output, file contents, or web responses directly into your conversation, context-mode runs everything in a sandbox. Only a printed summary enters your actual context. The raw data gets indexed into a SQLite database with full-text search (FTS5), so you can query it later without reloading it.
It gives Claude a handful of new tools that replace the usual chaining of bash and read calls:
- ctx_execute — Run code in a sandbox. Only your summary enters context.
- ctx_execute_file — Read and process a file without loading the whole thing.
- ctx_fetch_and_index — Fetch a URL and index it for searching, instead of pulling everything into context with WebFetch.
- ctx_search — Search previously indexed content without rerunning commands.
- ctx_batch_execute — Run multiple commands and search them all in one call.
There are also slash commands to check how much context you’ve saved in a session, run diagnostics, and update the plugin.
The approach is smart. All the data lives in a SQLite FTS5 database that you can index and search, surfacing only the relevant pieces when you need them. If you’ve worked with full-text search in libSQL or Turso, you’ll appreciate how well this maps to the problem. It’s the right tool for the job.
The benchmarks are impressive. The author reports overall context savings of around 96%. When you think about how much raw output typically gets dumped into a session, it makes sense. Most of that data was never being used anyway.
What This Means for Your Workflow
I think the broader lesson here is that context management is becoming a first-class concern for anyone doing serious work with agentic tools. It’s not just about having the most powerful model, it’s about using your token budget wisely so you can sustain longer, more complex sessions without hitting the wall.
A few practical takeaways:
- Be intentional about MCP servers. Load what you need, not everything you have.
- Use sub-agents for heavy lifting, but recognize they cost session tokens.
- Avoid dumping raw output into your main context whenever possible.
- Tools like context-mode can dramatically extend how much real work you get done per session.
We’re still early in figuring out the best practices for working with these tools. But managing your context window? That’s one of the things that separates productive sessions from frustrating ones.
Hopefully something here saves you some tokens.
/ AI / Programming / Developer-tools / Claude
-
I got tired of plaintext .env files, so I built LSM.
lsm execwill inject secrets at runtime so they never touch the filesystem. Doppler’s idea, minus the monthly bill.How are you managing local secrets?
/ Programming / Tools / security
-
I switched to mise for version management a month ago. No regrets. No more
brew upgradebreaking Python. Built-in task runner replaced some of projects that were using Makefiles.Still juggling nvm + pyenv + rbenv?
/ DevOps / Programming / Tools
-
I wrote about why you should stop using pip. Poetry or uv. Pick one. pip has no lockfile, no dependency resolution worth trusting, and no isolation by default.
Have you moved to uv yet? Still happy with poetry? How’s it going?
/ Programming / Tools / Python
-
I wrote about when to reach for Python over Bash. Most veterans put the cutoff at 50 lines. If you’re writing nested if statements in Bash, you’ve already gone too far.
Where does Go fit in though? Overkill for scripts?
/ Programming / Python / Bash
-
With AI agents, the cost of writing tests is approaching zero. Five words: “write tests on new code.” The excuses are running out.
Are you verifying the tests your AI writes, or just trusting the green checkmarks?
/ Programming / Testing / Claude-code
-
I compared npm, Yarn, pnpm, and Bun. TLDR version: pnpm wins for most teams, Bun wins if you’re already on the runtime.
Has anyone switched their whole team to Bun yet? How’d that go?
/ Programming / Tools / javascript
-
I wrote about securing node_modules. Socket, Snyk, Dependabot — each catches different things. Hopefully answering when to use AI to rewrite simple deps you barely use.
Anyone want to build that CLI?
/ Programming / security / javascript