• Fun with magnets!

    For most of my adult life I have been depressed. Over the past few years, as my family and I have endured a seemingly endless cavalcade of pain and suffering, my depression has gotten steadily worse. My sister’s death, our infertility, the loss of my cousin to cancer… it all takes its mental and emotional toll. My depression was recently supplemented by a new diagnosis — prolonged grief disorder. The Butlers have always been pioneers in various ways, so I suppose I’m proud to be the first Butler to be sporting a DSM-5-recognized diagnosis! A dubious honor, but it did help make sense of some of the things I’ve been feeling.

    I have not been alone in my fight against depression. My wife and family are with me. I have good mental health benefits which I take advantage of. I see a therapist. I also take medication, and the number and dosage of my medications have grown over the years as the fight has intensified. Sometimes it feels like I have to work incredibly hard just to stay afloat, let alone make any positive progress. I took three months of leave last year to try to get on top of it, and it helped. I’m glad I did it! But I’m still not in good shape.

    Which is what brought me to another treatment that sounds like hogwash but is real: treating depression with high-powered magnets. That’s basically what repetitive transcranial magnetic stimulation (rTMS) is. High-powered magnetic pulses are shot into my brain, where they induce a small electric current and help form new neural pathways. Pathways which, if all goes to plan, will help my brain better cope with the realities of depression. My goal is not to get off medication entirely, though that would of course be welcome. Rather, I just hope to reduce the need for so many meds, or make the meds more effective.

    What it’s like

    The science behind rTMS is pretty cool, and you can read more about it, but what you might not be able to find is a description of what the treatments are like. That’s what I hope this helps with. And note that I said treatments. This is not a one-time thing. rTMS is 8 weeks of 20-minute treatments at an office in Seattle. With the commute it’s about 80–90 minutes of commitment, every weekday. It’s a lot.

    Fortunately, the treatments aren’t uncomfortable for the most part. I don’t have to be strapped down or anything, but I do have to wear a skullcap that makes me look a little like a Mercury astronaut. Then the machine fits over my head and my head gets anchored in place. Having your head anchored to a beam isn’t really that bad as long as you don’t start to slouch or move your head too much. Then it becomes very apparent that your head is relatively immobile.

    Each session consists of three-second pulses of the magnet, spaced 20–30 seconds apart, for 20 minutes total. The pulses themselves are like a jackhammer — well, that implies pain, and it’s not really painful. So maybe a woodpecker? And it doesn’t feel like it’s hitting your skull directly. It’s more like it’s tapping on a helmet.

    There is an electric current being induced, though, and the pulses are going into my right forehead, so each pulse causes the right side of my face to seize up and my jaw to clench. The techs tell me that I am getting a relatively low power (which is good, because it means fewer side effects) and that some folks need to use a mouth guard because of the clenching. It sounds a lot worse than it is.

    At my first session, they also did a test where a single pulse was sent and they watched for movement in my thumb. They then adjusted the power of the magnet accordingly. This means that the first time you experience the magnet it’s not the full three-second woodpecker that is the standard experience.

    Trying to read without my glasses for 20 minutes is more annoying than the treatments themselves.

    Effects and side effects

    The biggest side effect I’ve had with rTMS is fatigue. They tell me that the treatments are essentially a brain workout, similar to a long night of hard studying. I get the treatments mid-afternoon because anything else would completely blow up my schedule, and afterwards it is often hard for me to get a lot done for the rest of the day. But I am an early riser anyway, and do my best work in the morning, so I manage OK. And one never grows too old to enjoy an afternoon nap!

    So far I haven’t noticed any miraculous healing or anything. Mental health rarely has quick fixes. That said, there are some signs that the dense fog of malaise is lifting. I have been enjoying video games to some extent again. Hades II and Outer Worlds 2 so far, but I am excited about Ocarina of Time as well. I have a little more natural curiosity about things, especially work-related things, than I have had in some time. I continue to work on a variety of side projects. All of this bodes well. If the trend continues, I should feel much better in a few months, and hopefully with less medication.

    Permalink →

  • Stop re-explaining your conventions in code review: Keeping a codebase coherent with repopo

    In her piece Test Coverage Won’t Save You, Jenn Cooper argues that coding agents spread incoherence across a codebase faster. One line stood out to me, because it’s proven true in my own projects:

    Every merged PR becomes a precedent.

    The natural outcome is a feedback loop. An agent copies whatever patterns it finds, and it can create and spread bad patterns all on its own.

    I’ve been building a tool called repopo that enforces repo policies: the conventions you’d otherwise explain in code review, PR after PR. I think it could also be useful for exploring the just-in-time agent coaching Jenn talks about: telling an agent what to fix, and what not to break, at the moment it’s working.

    Origins

    Repopo came out of my work on the Fluid Framework. We had a lot of rules we needed to enforce across the repo that didn’t belong to any particular linter. ESLint is great for code, but it’s not great for questions like, “Is this project set up properly?” “Does this package have a README?” “Does the README have the right sections?” Those rules are about the repo itself.

    So we built a policy check tool into our build tooling and made it flexible. It runs against files in the repo that match a regex, and it can fix many problems automatically. It worked well enough in Fluid that I wanted the same thing everywhere, but I didn’t want to bring Fluid’s build tools everywhere.

    The result is repopo: a lint tool for any file in your Git repo, with a straightforward way to write your own policies. It aims to be fast enough to run in your inner dev loop.

    Fitness functions

    Jenn recommends fitness functions: checks that protect properties of a system’s architecture rather than its behavior. Her examples include forbidding certain service calls and capping bundle growth. She also stresses what you tell an agent after a check fails — how to fix the violation and what it shouldn’t break.

    Now, you can write architectural checks in a test runner. Nothing stops a test from asserting import boundaries. But a passing test suite can’t tell you about something you never took the time to write a test for, and repo-wide tests take plumbing that most people won’t bother to build.

    Repopo makes those checks cheaper to write. Instead of writing a test that loads every file in the repo and checks each one, you define policies and repopo handles the common plumbing. You can write your own policies or use the bundled ones. A file policy matches paths with a regular expression and runs a handler for each matching file. The handler can return an error with instructions for a manual fix, or, if the change is mechanical, it can fix it.

    I use a bunch of the built-in policies for package metadata and scripts in my tools monorepo, but a repo-specific rule shows the connection to Jenn’s argument better.

    An example: one place for task config

    In my monorepo, each package’s package.json scripts hold the implementation commands, and nx.json holds shared Nx orchestration. Package-specific target settings go in package.json under nx.targets. But Nx also supports package-level project.json files, so a contributor — human or otherwise — could add one, configure a task, and pass every test.

    Sounds harmless, right? Except now there are two places to configure tasks, and the next person doesn’t know which one is right. With luck they’d follow the most common pattern, but they might follow the most recent one instead, and that sends them down the project.json path again. That’s the feedback loop I talked about earlier.

    So I banned it. In this repo, we don’t use project.json:

    const NoPackageProjectJson = {
    name: "NoPackageProjectJson",
    description:
    "Keeps Nx task orchestration centralized instead of adding package-level project.json files.",
    match: /^packages\/.*\/project\.json$/,
    handler: async () => [
    {
    message:
    "Package-level project.json files split task configuration across competing locations.",
    manualFix: [
    "Put implementation commands in the package's package.json scripts.",
    "Put shared orchestration and task defaults in root nx.json.",
    "Use package.json nx.targets for package-specific target settings.",
    "Follow packages/repopo/package.json and nx.json as examples.",
    "Preserve required tasks and caching settings when migrating; do not delete functionality to pass this check.",
    ].join("\n"),
    },
    ],
    } satisfies FilePolicyDefinition;

    There’s no auto-fix here. If repopo finds one of these files, something went wrong, and a person (or an agent) needs to fix it.

    You could also add a rule to your AGENTS.md that says, “Don’t add project.json; do it this way instead.” The problem is that agents, just like humans, don’t always follow the rules. You still need something deterministic to enforce them. Ideally your agents rarely break the rule, just like your humans rarely break it — but when they do, something reliable catches it.

    The manual fix steps describe what’s wrong and what’s right at the moment the contributor is paying attention, because they just made the change and the check runs in their inner loop.

    And don’t skip that last manual fix step: “Preserve required tasks and caching settings when migrating; do not delete functionality to pass this check.” Deleting the new project.json without moving its behavior would satisfy the path check and break the change. Jenn calls out this failure mode directly: an agent will optimize for a green check at the expense of the feature you asked for.

    Repository policies

    Some rules need more context than a single file. For those, repopo has repository policies, which run once after the file policies finish.

    The built-in SharedDependencyVersions policy is a good example — it checks that members of a Cargo workspace agree on dependency versions. It’s a Rust-specific policy, but the same API is there for your own cross-file rules.

    Could you use them for Jenn’s example of tool handlers calling AI services? Sure, but it’s harder than it sounds. Do you care about direct imports only, or calls through intermediate modules? What about import aliases? Dynamic imports? A text search for one import string will leave gaps. If your linter or a dependency-analysis tool already handles the boundary you need, use that. Repopo is useful for checks that don’t have a better home, but it’s not a general-purpose replacement for existing static analysis tools.

    Ratchets

    Jenn also describes ratchets for measurable things like bundle size: set a ceiling at the current value, then fail the check when a change increases it by too much.

    I wouldn’t use repopo for bundle size. You can’t measure a bundle until you build it, and a full build is too slow for a check you run in your inner loop. Bundle size checks should run in CI at minimum, and a dedicated bundle-size tool will do the job better than anything you write yourself. Those tools already handle compression, per-entry-point budgets, and reporting the change on each PR.

    Ratchets on source files are a different story. Repopo already walks the files in your repo, so a custom policy can count lines or find files that still use a deprecated pattern without waiting for a build. Have the policy compare the result against a limit in committed config and report both numbers when it fails.

    Pick the number with care, though. A file-length limit sounds reasonable until an agent splits one understandable module into four awkward ones to get under it. Put that in the manual fix, too: a split has to leave each module easier to read.

    What repopo won’t do

    There’s a lot repopo leaves up to you:

    • No built-in ratchets. There are no historical baselines. You write the measurement and comparison, and you own the review process.
    • It only sees what Git sees. The default file inventory comes from Git, so ignored build output won’t show up. That’s one more reason to leave bundle size to a dedicated tool.
    • It’s not a commit gate. Repopo exits nonzero when there are unresolved violations. Wiring that into CI or a local build is up to you.
    • It can’t judge design. It can reject a forbidden file. It can’t tell you whether two helpers express the same idea, or whether a new abstraction is worth the extra code.

    That last one is where the agent-based reviews Jenn describes come in, including reviews that flag tests preserving patterns the team wants to retire. I’d run those alongside deterministic checks. When a reviewer keeps flagging the same problem, ask whether the rule is precise enough to become a policy. If it is, write the policy and stop leaving the same review comment.

    Try it

    To try it, install repopo globally or, if you’re working on a TypeScript/JavaScript project, into your project as a dependency.

    Without a config file, repopo runs a small default set of policies. To use your own, create repopo.config.ts in the root of your repo and register them with makePolicy:

    import { type FilePolicyDefinition, makePolicy, type RepopoConfig } from "repopo";
    const NoPackageProjectJson = {
    // The policy from earlier in this post
    } satisfies FilePolicyDefinition;
    const config: RepopoConfig = {
    policies: [makePolicy(NoPackageProjectJson)],
    };
    export default config;

    Then run:

    Terminal window
    repopo check # report violations
    repopo check --fix # fix what can be fixed automatically

    If you installed repopo as a project dependency, prefix those commands with npx.

    I’d put the read-only check in CI and ask agents to run it before they declare a task done. The docs cover the rest, including partial checks.

    If you’ve got a convention you’re tired of re-explaining in code review, give repopo a try. If you write a policy other people could use, send a PR, or export it from your own package like I do with sort-tsconfig.

    Permalink →

  • Bundling Gleam dependencies with trellis 1.1

    Beryl is my library for type-safe channels over Web Sockets. I eventually want to publish it to Hex so that others can find and use it with as little friction as possible. Beryl as a whole targets the Erlang runtime, because it has hard dependencies on BEAM infrastructure and is primarily a server-side library.

    However, there is a piece of beryl that can target both JavaScript and Erlang — the state machine of the core protocol. I wanted to break that piece out so that I could find out whether a beryl-like runtime could run on Cloudflare Durable Objects. Spoiler alert: it can, but more on that in a future post.

    I broke out the core state machine into its own package, beryl_protocol, which you can think of as the pure rules for a connection. It tracks things like: Has this client joined this topic? Is a join still waiting for approval? Can this reply still be sent? Has the connection timed out?

    The rest of beryl does the actual work: it calls your code, sends data, and manages processes. All of that code remains in the beryl package, which is tightly coupled to the BEAM.

    This separation raised a question: what should I do with the beryl_protocol package when it comes time to publish to Hex? Right now, with Git-only releases, consumers can just use beryl directly without knowing that beryl_protocol exists. It is an implementation detail of how the code is structured, not something users should think about.

    I could publish beryl_protocol as a separate library. Maybe someone else would use it! (Hubris!) But I don’t want to create a separate release just because I found a useful separation within my project. Packages such as beryl_mist and beryl_ewe give users a choice of transport, which makes separate releases useful. There’s no compelling reason to offer the protocol package on its own, especially not just to satisfy a Durable Objects experiment, which can use a Git dependency instead.

    I wanted to keep beryl_protocol as a separate package in the repository so I could build and test it on both Erlang and JavaScript. For Hex, though, I wanted to ship its source inside beryl.

    Since I already use trellis to manage releases, I added source bundling in trellis 1.1. It’s specifically designed around beryl’s needs, but I think it will suit other similar cases where the package boundary makes sense inside a project but not outside it. My root gleam.toml includes:

    [tools.trellis.publish.bundle]
    beryl = ["beryl_protocol"]

    When I publish beryl, trellis creates a temporary package with both source trees. It preserves the module names and imports, removes the separate beryl_protocol dependency, and includes its external runtime requirements. It then validates and publishes the combined package without changing my checkout. Conflicts, such as duplicate module names or differing dependency requirements, stop publication. Those checks only cover the package I’m publishing. A consumer who installs beryl alongside a separate copy of beryl_protocol can still hit a module collision.

    Consumers will install beryl without a separate protocol release, and I keep a package boundary that holds the protocol honest about JavaScript support.

    The trellis source bundling docs cover the details, and the beryl workspace configuration is a working example.

    Permalink →

  • What if we applied the Elm Architecture to Web Sockets?

    I’ve been working on a project for the past few months called beryl. It attempts to bring a real-time channels abstraction (rooms and topics, for those familiar with the space) to Gleam. A channel is a named group of connected clients that exchange real-time messages. My goal is to make it easier to build real-time applications, web applications specifically, in Gleam.

    I’m fairly new to Gleam, and this is the first big project I’ve built with it.

    I’m also using LLMs a lot. Now you know.

    Starting from Phoenix

    When I started this project, I used Phoenix Channels as design inspiration. Phoenix Channels is written in Elixir. It’s in the BEAM ecosystem, but it’s built in the Elixir language, and therefore has some different programming models and styles that make sense in the Elixir world but don’t necessarily make sense in Gleam.

    I don’t know Elixir, so I borrowed Phoenix’s design rather than its code, but in the end it doesn’t really matter, because a lot of the code stems from the design.

    I talked about the overall design and experience of that initial version here:

    I liked the Gleam API, but it had a type-safety problem. To keep sockets with different message types in one registry, beryl v0.0 erased those types at the registry boundary. Code on the other side had to recover information the compiler could no longer verify. If you’re curious, you can still use beryl v0.0. I did some clever (to me, anyway) things to make sure that the type erasure was as safe as possible, but it still felt like a compromise compared to a fully type-safe approach.

    Feedback

    The clearest feedback from the Gleam Discord was that the heterogeneous registry worked against the language. I had copied a Phoenix design, then used type erasure to make it fit. The result felt more like Elixir than Gleam.

    They were polite and convincing, so I went back to the drawing board.

    I took a harder look at Lustre, which implements the Elm Architecture. Further research revealed that Gleam’s OTP actor model uses a very similar state-transition design.

    Could the same state-transition design work for socket state?

    Model-View-Update, but sockets?

    What if we took the Elm Architecture of state transitions and applied it to socket state? That’s the question the new design is built around, and it turns out to work pretty well. Lustre and OTP actors already follow the same four steps: create some state, define the typed messages that can arrive, handle one message against the current state, return the next state plus whatever should happen next. Only that last step differs. Lustre renders a view. An actor continues or stops.

    beryl returns effects that turn into frames over the wire.

    RoleLustreOTP actorberyl
    statemodelactor stateone model per socket
    inputMessagemailbox messagesocket.Input(Message)
    transitionupdateon_messageupdate
    next step#(model, effect)actor.Nextsocket.Next(model, effects)

    In raw dispatch, the core beryl API, the loop looks like this:

    import beryl/socket
    import gleam/option.{None}
    type Model {
    Model(joined_topics: Int)
    }
    fn init(_info: socket.ConnectInfo(Nil)) -> #(Model, List(socket.Effect)) {
    #(Model(joined_topics: 0), [])
    }
    fn update(model: Model, input: socket.Input(Nil)) -> socket.Next(Model) {
    case input {
    socket.Join(_topic, _payload, ref) ->
    socket.Next(Model(joined_topics: model.joined_topics + 1), [
    socket.AcceptJoin(ref, None),
    ])
    // ...more input handling here...
    _ -> socket.Next(model, [])
    }
    }

    There’s no view, which changes how you think about output. Changing the model doesn’t send anything over the wire. A frame goes out only when an effect asks for one — accept this join, reply to this client, broadcast to this topic, update presence, close a topic. Output is an explicit, ordered list, so the model is free to hold things that never leave the server.

    The other difference from a frontend is that init runs once per connected socket, not once per application. Every socket is its own actor with its own model. Shared state — the poll totals everyone in a room needs to agree on — lives in an actor your application owns, and each socket talks to it. Per-socket state and shared domain state stay apart.

    Play with the poll below to see what happens when three users vote and disconnect. Most of the beryl state is ephemeral, but what’s in your storage actor outlives the sockets.

    Try the shared poll.

    Two APIs

    Both layers use the same loop. Which one you use depends on how much routing you want to handle yourself.

    Raw dispatch is the core. You give beryl an init and an update, and every event on a socket arrives at your update as a single socket.Input value: joins, client messages, binary frames, close events, and your own typed server messages. You do the routing for all of it. You return socket.Next(model, effects) or socket.Stop(reason). It’s the clearest place to see the whole loop, which is why the beryl tutorial starts there.

    Channels sit on top and do the routing for you. A channel is a topic pattern plus a typed join callback that either rejects the join or accepts it with private state and a few callbacks — on_message, on_info, on_terminate. You register a list of handlers, patterns match in registration order, and each joined topic gets its own instance and its own state, pruned automatically when the topic closes.

    The part I’m happiest with is that channel state is typed all the way through. Phoenix keeps per-channel state in socket.assigns, a map of atoms to untyped terms. A beryl channel keeps a value of its own type, known to the compiler, and unrelated channels can still live in the same handler list. Nothing gets stuffed through Dynamic. The same is true of server-side messages: notify delivers your own type to the right join’s on_info, and a sender only ever addresses the join that produced it.

    Channels return actions instead of effects, but they work the same way effects do, with one useful constraint — an action can only touch its own channel’s topic. Cross-topic work has to go through an explicit API. That felt restrictive when I wrote it, but after more thought, I came around to it.

    If you have more than one topic namespace on a socket, the channel layer is what you want. If you want one topic family and total control, raw dispatch is right there underneath, and moving between them doesn’t change the wire protocol.

    Going deeper

    I’ve skipped a lot here. The tutorial on the docs site builds a live poll end to end — the raw loop first, then the same poll moved onto channels, then typed server messages, presence, and supervision. That’s the deep dive for anything I described that you want to see in more detail.

    The new API replaces the heterogeneous registry with typed channel state and explicit effects. More to the point, it feels a lot more like Gleam than what I had before, which was the whole idea.

    Permalink →