• Bundling Gleam dependencies with trellis 1.1

    Beryl is my library for type-safe channels over websockets. 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 the BEAM infrastructure and is primarily a server-side library.

    However, there is a piece of the beryl runtime 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 application ultimately decides what messages mean — send a chat message, update a document, cast a vote. The surrounding beryl runtime does the actual work: 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 presented a decision, though. 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 an experiment with Durable Objects, 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"]
    [tools.trellis.publish.lifecycle]
    default = "git_only"
    packages = { "packages/beryl" = "hex", "packages/beryl_protocol" = "workspace" }

    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 needing a separate protocol release, while I get the convenience of a separate package boundary to help with development and keep 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 →

  • Git-only releases with trellis

    One challenge with modern software distribution, especially for libraries, is that you need to get feedback on an API before stabilizing it, but some publishing platforms reasonably discourage publishing pre-1.0 packages, because such packages cannot adhere strictly to Semantic Versioning.

    But you still need to distribute such software in some way if you want to get feedback. In Gleam, the answer is to ask users to take a dependency on your source in git. In Gleam 1.18, this received a major upgrade with support for paths within a git repository, making git dependencies on monorepos a real thing. I rely on it heavily.

    But just because the pre-1.0 world is technically the Wild West doesn’t mean we can’t get in the habit of following a release process prior to 1.0. However, because “doing a release” is so often synonymous with “publishing to a registry” oftentimes the first time you’re exercising the release machinery is during a 1.0. Not fun!

    With my Gleam monorepo tool, trellis, I added support for a git-only release style, which allows you to use the same change tracking and release mechanism you use to eventually publish your work to a registry without actually doing that. But you can still do every other part, including using change tracking to determine release types, tagging each package individually in a monorepo, and create a rolling tag that moves as minor or patch versions are released.

    The rolling tags are the flagship feature, because they give your users something closer to a version range like they would have on a registry. They can depend on a moving series tag like v0, and get updates automatically every time they re-resolve.

    That is useful, but it creates a second problem: a moving ref is not a pin. It is great when you deliberately want to pick up whatever the latest compatible release is supposed to be, but less so when a routine dependency operation moves you from one commit to another before you have looked at what changed.

    So the workflow needs two refs, not one. The series tag is the update target. The commit is the thing you actually trust. When you want updates, you resolve against a tag like v0, inspect what you got, and then run trellis pin to rewrite the moving ref to the exact commit you accepted.

    That is the scenario trellis pin is built for. Library authors can publish git-only releases with useful series tags. Library users can start from those tags without leaving a floating dependency in their project. If you’ve used ratchet or similar tools to pin GitHub actions, then this will be very familiar.

    With repository series tags and trellis pin, you can support users taking git deps on your projects while still giving them a clean path to intentional updates for security fixes, new features, etc.

    And worry not — you can use trellis pin on its own without adopting anything else. If you already depend on git refs and just want to turn them into commits, that’s fine. Trellis remains modular. Use what you need and ignore what you don’t.

    Note that trellis doesn’t make any claims about breaking changes or where they’ll show up. You should still pin according to the amount of change you can absorb. In general, pinning to a major version is safe after 1.0, but for 0.x be sure to check the compatibility promises the library in question provides.

    Some may follow a sort of “pre-1.0 SemVer” where breaking changes are promised to come only in minor releases, never in patches. (For what it’s worth,this is what I strongly recommend libraries follow pre-1.0.) In such cases it may be safe to take a minor version-series pin. When in doubt, though, use an exact ref.

    Permalink →

  • trellis: workspace tooling for Gleam monorepos

    As I’ve been building more libraries in the Gleam ecosystem, I’ve taken advantage of Gleam’s support for file system dependencies. Path deps let you create local workspaces with multiple Gleam packages linked and built together. It’s great!

    The first workspace I set up was for my lattice project, a collection of CRDT implementations. Each data structure is packaged individually, so you can depend on just the counter or just the OR-set without pulling in everything else, and each package can version and release on its own schedule. This is clearly the right shape for lattice.

    It also surfaced two problems.

    Running things in the right order. Within a single package, Gleam handles path deps well — edit a dependency’s source and the consumer’s next build picks it up. But that’s where the workspace awareness ends: gleam build, gleam test, and gleam publish each operate on one package directory at a time. Running tests across the whole workspace — or just across the packages a change actually affected — meant hand-maintaining the package list in topological order in a justfile and looping over it serially in bash. Every new package had to be added to the list by hand, and nothing verified the ordering as the dependency graph evolved. That’s fine with two packages and increasingly not fine after that.

    Releasing. Publishing a multi-package workspace to Hex meant a pile of bash glue: computing publish order by hand, and — the worst part — rewriting each package’s path dependencies into valid Hex version requirements at publish time, then putting the originals back afterward. That step was the most annoying and error-prone thing to automate, because a half-failed release leaves your repo in a rewritten state.

    I also initially struggled with how to handle versioning across the packages, which I believe should work a specific way: every user-facing change gets recorded as a small fragment file when it lands, and releases batch the accumulated fragments into version bumps and changelog entries. Fortunately there’s an excellent tool called Changie that automates exactly this workflow, and I highly recommend it. I use it for my Rust projects — including, as it happens, this one.

    After running into my third project that needed the same Gleam workspace infrastructure, I decided it was time to solve the problem properly.

    Enter trellis

    I happen to have quite a bit of professional experience in this area, so I wrote down the scenarios and sketched the CLI, then dispatched Fable 5 to put it together. I was reasonably impressed with what it produced, and I’m already using it in my projects.

    The result is trellis — a trellis being, of course, the frame a lattice grows on.

    The design principle: configure nothing that can be derived, verify anything that must be duplicated. There’s no separate workspace config file. A [tools.trellis] table in the root gleam.toml marks the workspace and lists member globs; everything else — the dependency graph, build order, publish order, change impact, the path-dep rewrite map — is computed from the members’ own gleam.toml files, never declared.

    # gleam.toml at the repo root
    [tools.trellis]
    members = ["packages/*", "examples/*"]
    exclude = { "@release" = ["examples/*"] }

    That’s enough for the day-to-day commands:

    Terminal window
    trellis run test # graph-parallel: a package runs as soon
    # as its workspace deps have finished
    trellis run test --since origin/main --with-dependents
    # only what a PR touched, plus dependents
    trellis graph --format mermaid # see the topology
    trellis doctor # check every workspace invariant at once

    And for releases, the part I actually built it for:

    Terminal window
    trellis changelog new --kind Added --body "..." # record a fragment
    trellis release pr # fragments -> release PR
    trellis tag create --push --github-release # tag merged versions
    trellis publish --all-untagged # publish to Hex, in order

    publish handles the path-dep rewrite that used to be my least favorite bash script: each workspace path dependency becomes a Hex requirement derived from that dep’s current version, gleam publish runs, and the original gleam.toml is restored — even on failure. Already-published versions are skipped, so re-running a partially failed release is safe.

    Opinionated but modular

    Trellis is opinionated, but the pieces are modular, so you can adopt only the ones you want. If you just want run and graph, use those and keep your existing release process. I also built the changelog engine in as a native, Changie-compatible feature — fragments in .changes/unreleased/, configurable kinds and bump rules — because it’s nice to have a single binary that handles the whole workspace scenario end to end, in CI and locally alike. But if you don’t like how it works, sub it out.

    I also welcome bug reports, feature requests, pull requests, etc. on the trellis repo. It’s MIT-licensed open source.

    It ships as a single prebuilt binary (shell installer, Homebrew, mise, or cargo install), so it installs in CI in about a second. There are comprehensive docs at trellis.tylerbutler.com. If you’re building more than one Gleam package in a repo, take it for a spin.

    Permalink →

  • Introducing CCL

    In package.json considered harmful, I made the case that JSON is a poor config format and that JSONC and JSON5 don’t actually fix the problem — they just extend the lifespan of a bad bet. If comments are a requirement (and I think they are), and if simplicity matters, then the JavaScript ecosystem’s default config story is genuinely broken.

    But pointing out what’s wrong is the easy part. What should you use instead?

    I’ve been thinking about this for a while, and I want to tell you about a language I stumbled onto recently that I think gets it right — or at least, gets closer than anything else I’ve tried.

    The second criterion

    I said in package.json considered harmful that comment support is the minimum bar for a config language. But there’s a second criterion I didn’t spend much time on: simplicity. It needs to be simple to hand-author, simple to read, and simple to understand.

    This sounds obvious, but it’s surprisingly easy to fail. Which brings me to my first encounter on the road to finding something better.

    Discovering PKL (and rejecting it)

    I’ve been using mise for managing dev tools — it’s excellent for polyglot environments where you’re juggling Rust and Node.js toolchains simultaneously. The same author released a tool called HK, a Git hook manager. I tried it out, and the way HK is configured is using a language called PKL.

    I’d never heard of it. So I started researching, and wow — PKL is not a configuration language. PKL is a programming language disguised as a configuration language. It’s far too complicated. It has far too many features.

    PKL does support comments, so it clears the first bar. But it fails the second one badly. At the point where your config language needs a runtime and an execution model, the honest question is: why aren’t you just writing code? The complexity cost is real; the benefit over simply using a programming language is not.

    Machines are, in some ways, secondary to this conversation. We believe this about programming languages — nobody designs a language to make it easier for the computer. We design syntax and semantics to help the programmer, then do extra work in the compiler. We go to all that trouble because we believe people are more productive in languages that are easier to read and write.

    Config languages carry the same obligation. They should be easy to read, easy to write by hand. PKL seems to have forgotten this.

    Finding CCL

    While I was working through PKL’s documentation and finding new things to be frustrated by, I came across a blog post by Dmitrii Kovanikov. I found myself nodding along, saying “yes, this all makes sense.” It was about a configuration language Dmitrii called Categorical Configuration Language.

    What makes CCL elegant

    Consider how you’d jot down a list on paper. You’d probably write a heading, indent a bit underneath it, maybe use a dash for items. If something belongs under another item, you’d indent further. Maybe it would look something like this:

    Errands
    - Groceries
    Fruit
    - Apples - 2 lb
    - Bananas - 1 bunch
    - Crudite platter
    - Cereal
    - Milk
    - Vet - Spot's surgery ($500 deposit, ask about "recovery diet")

    Here’s what a basic CCL config looks like:

    /= Errands
    Groceries =
    Fruit =
    Apples = 2 lb
    Bananas = 1 bunch
    = Crudite platter
    = Cereal
    = Milk
    Vet = Spot's surgery ($500 deposit, ask about "recovery diet")

    That’s it. Keys and values separated by =. Comments are just entries with / as the key — not special syntax, just a convention. Nested values are indented. Lists are created by empty keys — much like the human-made list might use a dash to denote a list item. There’s nothing else to learn.

    I know there’s a cohort of programmers for whom significant indentation is an unforgivable sin. I have genuinely mixed feelings about YAML, which also uses indentation for structure, and I understand the frustration. But in CCL, the indentation is doing something different — and the distinction matters.

    The key insight is this: every time a language uses a special character to delimit structure — {, }, [, ], ", | — it creates an escaping problem. What if your value contains that character? Now you need escape sequences. And if your value is a shell command that already contains escaping, you end up double-escaping, which is a special kind of misery.

    Whitespace doesn’t have this problem. You rarely need to escape a space. Nobody writes a shell command and worries it contains too many leading spaces. Dmitrii calls whitespace “silent ninjas” — they do structural work without being visible characters anyone would ever need to include literally in a value. YAML uses indentation too, but pairs it with a large surface area of other syntax. CCL uses indentation instead of that other syntax. That’s a meaningful distinction.

    There’s also a deeper mathematical elegance here that I’ll only gesture at: CCL configs compose associatively, and an empty config is a valid identity element, which means the whole thing forms a monoid. If that means something to you, Dmitrii’s post goes much further down that road — it’s one of the more satisfying things I’ve read about config design.

    Minimal syntax in practice

    There’s something else really powerful about CCL. The only characters that are processed in a special way are a newline and an equals sign. And the equals sign is only special when it’s the first one on a line — every other equals is not special.

    This means you can use CCL to embed other languages quite easily. But it also happens to be really useful for apps that need to store shell commands. Escaping is always a pain when you have a shell command that also needs to do escaping in the shell — double escaping is hard.

    CCL solves that because there’s no escaping. You get a string that represents the exact string you need to run in the shell. I make heavy use of this in my app Santa, where every package source is essentially a set of shell commands. It’s very easy to configure in CCL with no weird escaping rules to explain.

    Building with LLMs

    The other reason I found CCL interesting is that it’s been a fun project to explore with LLMs and different programming languages. I have an interest in lots of languages but I’m not fluent in all of them. I can read code and understand what the code does, but I couldn’t necessarily write it from scratch without a lot of references.

    So I had fun creating a library of test cases, then a test harness, then instructions for how to build a test harness in any language. I used that harness to build CCL parser implementations in Rust, Gleam, Go, and TypeScript. Each one was a chance to learn something about the language while also building something genuinely useful.

    CCL isn’t competing with JSON

    Before I go further, I want to be direct about something: I’m not arguing that CCL should replace JSON. That’s not the point, and it’s not a realistic claim.

    JSON’s ubiquity comes from being a data interchange format — something every language can produce and consume, with a spec stable enough that parsers written a decade apart still agree. That’s genuinely valuable, and CCL doesn’t offer it. CCL has no ambitions there, and it shouldn’t. The two formats are solving different problems.

    The argument I’m making is narrower: when you’re choosing a config language and you actually have a choice, CCL deserves serious consideration. Most of the time when we reach for JSON as a config format, we’re not making a deliberate choice — we’re just following the path of least resistance. npm did it, the tools around npm did it, and now the question feels settled before it’s been asked.

    But there are plenty of contexts where the question isn’t settled. You’re building a new tool. You’re designing the config format for an app you own. You’re starting a project that isn’t already embedded in the JSON ecosystem. In those moments, you have a real choice, and the default answer isn’t necessarily the right one.

    CCL will likely never be as popular as JSON, and that’s fine. Popularity follows adoption, and adoption follows ecosystem gravity — JSON has decades of that. What CCL has instead is a set of properties that make it genuinely well-suited to the specific job of human-authored configuration: minimal syntax, no escaping problems, comments as a first-class concept. Those properties don’t help you interchange data between microservices. They do help you write and maintain config files that real people have to read and edit.

    That’s a smaller job than what JSON does. It’s also the job JSON has always been bad at.

    What’s next

    After spending time with this, I wanted to do more than just build parsers for my own use. I’ve created a GitHub organization — CatConfLang — to collect CCL implementations across languages and build out the ecosystem a bit. The reference implementation is Dmitrii’s OCaml version, but there are now parsers in Rust, Go, TypeScript, and Gleam, each developed against a shared test suite so behavior stays consistent.

    I also built a comprehensive test suite that I continue to improve and a website, ccl.tylerbutler.com including LLM prompts for folks who want to explore LLM coding agents on a “real project.”

    If you find CCL interesting, take a look and get in touch! The spec is simple enough that you could implement a parser in an afternoon in whatever language you know best. That’s kind of the point.

    Permalink →