Annually-Funded Developers' Update: Sept. and October 2026

Hello Fellow Clojurists!

This is the fifth of six reports from the developers who are receiving annual funding for 2026. Thanks to everyone for supporting their work and these important contributions to the Clojure community. Their previous reports can be found here:
January/February 2026
March/April 2026
May/June 2026
July/Aug 2026


Bozhidar Batsov: nREPL, neorepl, piggieback, CIDER
Clojure Camp: Conj activities, Recurse Center
Eric Dallo: ECA, ECA CLI, clojure-lsp
Jeaye Wilkerson: Jank portability, parity, stability, usability, and performance
Michiel Borkent: Squint, Babashka.ffi, clj-kondo, antq, and much more

Bozhidar Batsov

2026 Annual Funding Report 5. Published Nov. 6, 2026.

For a change, most of my focus in September and October was on nREPL itself, not on CIDER. The big themes were the nREPL protocol spec and how well the servers and clients out there actually stick to it. For years building an nREPL server or client that plays well with the rest of the ecosystem came down to trial and error, and this time I finally did something about it. It paid off much sooner than I expected. The highlights:

Read moreShow less
  • proof, a compatibility suite for nREPL servers and clients, is out, and it’s already found (and helped fix) problems in nREPL itself, Babashka, CIDER, Calva, Conjure and many other servers and clients
  • neorepl, a brand new terminal REPL meant to be something like “CIDER in a terminal” and “CIDER for agents”, went from 0.1 to 0.4 in two weeks
  • nREPL 1.8 is out, and master already has fixes for some of the bugs proof found in it
  • Piggieback 0.7.1 and 0.8.0 fixed a few long-standing problems with ClojureScript REPLs
  • nrepl.org became a proper landing page (with an nREPL server running right in your browser), the manual moved to docs.nrepl.org, and CIDER got a landing page of its own at cider.mx
  • CIDER’s master gained streaming test results and commands for editing the ns form, backed by new cider-nrepl and Orchard releases

Below are the details, project by project.

proof and the nREPL spec

proof is the big new project this time around, and the one I’m most excited about. I’ve been talking about some automated way to check nREPL compatibility for ages (it made my list of things people hate about nREPL back in 2023), so it feels really great to finally see it become a reality. Good things take a while to happen, they said… Does your server work with CIDER? Until now the only way to find out was to implement the protocol, connect CIDER (or Calva, or whatever your users run) and see what breaks. Now you can point proof at your server and it’ll tell you. It’s a single binary that runs about 80 checks against a server. A check fails only when a real client would break (with a link to the client code that depends on the behavior), and everything else is just a warning. proof also comes with client profiles that send exactly what CIDER, Calva, Conjure, vim-fireplace, REPLy and neorepl send, profiles for the servers I know of, and a GitHub Action for running it in a server’s CI.

Once the server side was in place, I started on the clients as well. proof proxy sits between a client and a server and checks what the client sends, and proof serve is a small server for the test suites of clients, which can mimic the quirks of nREPL, Babashka, jank and others (proof serve -like jank). I wrote about proof in a lot more detail in proof: How Strong Is Your nREPL?.

  • 0.1.0 came out on October 26.
  • 0.2.0 followed a week later, along with a compatibility matrix that runs proof against every server it knows about daily.

proof goes hand in hand with the nREPL spec, the draft Phil Hagelberg wrote earlier this year. When the spec and the existing clients disagree, proof goes with the clients (a spec that breaks CIDER isn’t much use to anyone), and every such conflict, along with the gaps proof ran into, ends up on a list of spec changes. The first few of them are already PRs against the spec: how replies relate to their requests, the status values, how interrupts are answered and the fields of lookup replies.

What I like the most about proof is how quickly it started paying off:

  • nREPL itself sent the done of an eval interrupted from another connection to the wrong connection (fixed on master), drops the evals waiting behind an interrupted one (#503) and can’t interrupt an eval that hasn’t started yet (#504). Not great!
  • Between proof and neorepl, Babashka got a couple of dozen issue reports from me in October, from test ops that never answered to a session that stopped answering after its client went away in the middle of an eval. Michiel Borkent fixed nearly all of them the same day or the next. You rock, Michiel!
  • CIDER didn’t finish setting up connections to servers without cider-nrepl (fixed), Calva and Conjure didn’t sort the keys of the dictionaries they sent (both fixed within days), vim-fireplace and REPLy never close their sessions, and REPLy’s and Conjure’s interrupts don’t say which eval to stop.
  • Basilisp and jank got a few reports as well (lookup replies, and a request that throws taking its whole connection down).
  • My own toy implementations (nrepl-beam, mezcaml and neat) were the first projects I ran proof against (more on them below).

I was also super happy to see other people using proof already. Tom Waddington has been running it against his nREPL implementations for Janet and Steel Scheme since early October (nrepl-janet even tests its client with proof serve), Jeaye Wilkerson suggested using it for jank’s nREPL server, and Kyle Cesare is already fixing what it found there. That’s exactly what I was hoping for!

neorepl

neorepl is the other brand new project, and it grew up together with proof. It’s a terminal REPL for Clojure (and anything else that runs an nREPL server), and the best way I can describe it is “CIDER in a terminal”: it uses cider-nrepl when it’s around, the same way CIDER does, and falls back to nREPL’s built-in ops when it isn’t. Every lookup the REPL can do is also a command with structured output, and there’s an MCP server and a skill for coding agents, so it’s “CIDER for agents” as well. Five releases in three weeks:

  • 0.1.0 had the REPL with a proper line editor (highlighting, completion, arglists as you type, history per project, a Ctrl-C that actually interrupts), CIDER-style REPL commands, jack-in for Leiningen, the Clojure CLI, Babashka and shadow-cljs, and commands for evaluating code and looking things up that keep their session between calls. It runs on the JVM and on Babashka.
  • 0.2.0 added a test runner (Clojure and ClojureScript), a full-screen inspector and a stacktrace browser.
  • 0.3.0 was all about coding agents: neorepl mcp, a skill that teaches agents to check, load, test and debug Clojure code with the CLI, and neorepl check --fix, which fixes mismatched delimiters the way parinfer does.
  • 0.4.0 brought tracing, switching the REPL into another named session (e.g. to take a look at what your coding agent is doing), and a huge list of fixes.
  • 0.4.1 caught up with cider-nrepl 0.64.

proof checks every server with the exact requests neorepl sends, and neorepl went through proof proxy and every scenario of proof serve before 0.1 was out. Its commands are separate processes sharing a session, so neorepl hits nREPL’s interrupt bugs harder than most clients, and making it work as well on Babashka’s server as with cider-nrepl is where most of the Babashka reports came from. Even JLine got a few bug reports along the way.

By the time I got around to writing this update, neorepl 0.5 was out as well, with a much smarter line editor, a namespace browser, automatic reloading and a CLI that editors can use without a plugin. I wrote about it in neorepl 0.5: CIDER in a Terminal.

nREPL

nREPL 1.8.0 came out on October 2 with the URL-based connections and the TLS improvements I mentioned last time, plus a few session fixes. Sessions survive evaluated code closing *in* or *out*, a race that could split responses between concurrent reads in nrepl.core/client is gone, and Sashko made persistent sessions re-create their thread when a faulty exec thunk kills it. He also moved the Unix sockets to the SocketChannel API.

Master moved on quickly after the release, much of it because of proof and neorepl:

  • the done of an eval interrupted from another connection goes to the eval’s own connection, where the client that sent the eval never got it
  • the server sets TCP_NODELAY on accepted connections, so on Linux an eval’s done no longer waits up to 40ms behind its value
  • Sashko replaced :out-limit with separate :out-quota and :err-quota limits, made the bencode transport turn empty lists into nil for ?-ending keys and made building the middleware stack faster

The interrupt bugs that are still open are next on my list.

Piggieback

  • 0.7.1 fixed load-file failing with “No such namespace” when the file requires a foreign lib (e.g. a cljsjs package).
  • 0.8.0 fixed :cljs/quit on nREPL 1.3+, where the session stayed in ClojureScript mode, and made evaluation use the same dynamic bindings as cljs.repl. That means the cljs-repl options (e.g. :warnings) and the bindings tools like figwheel-main set up around it finally take effect, which closes an issue from 2018. It also picked up :as-alias keywords and the :cljs branch of data_readers.cljc.

nrepl.org and cider.mx

nrepl.org got a facelift at the end of October. The manual moved to docs.nrepl.org, and nrepl.org itself is now a landing page that explains what nREPL is and how its protocol works, with real exchanges between a client and a server next to every feature. It even has a “Try it” section with an nREPL server running right in your browser, which turned out to be a story of its own (see the blog posts below). That playground is how nrepl-sci, a small nREPL server for SCI that runs entirely in a web page, came to be, and it even led me to take a closer look at clojure-mode for CodeMirror, which powers the playground’s editor.

CIDER got a landing page of its own at cider.mx, made to look like an Emacs frame running org-mode (mode line, M-x and all), with mini screencasts of CIDER in action. I had a lot of fun working on both sites!

neat, mezcaml and nREPL on the BEAM

My toy nREPL implementations were the first things I ran proof against, and all of them got a round of fixes in early October:

  • dialtone and repartee, the Erlang and Elixir servers in nrepl-beam, now answer close with session-closed, echo the op in unknown-op replies and return a value for every Erlang form in an eval
  • mezcaml no longer lets late output from one eval end the next one early, interrupts on Ctrl-C, answers stdin requests, closes its session on exit and completes on jank and Basilisp
  • neat shows late and id-less output, reports error statuses that come without err, handles unknown sessions and stdin EOF, and checks describe before using completions, lookup and interrupt

neat’s own integration suite now tests just the client and leaves the servers to proof. None of the three has had a release yet, though.

CIDER

No CIDER release this time (2.1 is still brewing), but master kept moving:

  • Test results now stream in while the tests run (cider-test-stream-results, on by default): the echo area keeps a running count, and failing tests land in the report as soon as they finish. This closes an issue from 2014! cider-test-fail-fast is now off by default to go with it.
  • A family of commands for editing the ns form: cider-ns-add-require, cider-ns-add-import, cider-ns-add-missing (adds what the symbol at point needs), magic requires (typing alias/ adds the require the alias needs) and cider-ns-clean. These are some of clj-refactor’s most popular features, and they’re backed by new cider-nrepl ops that read your code instead of loading it.
  • Connecting to a server without cider-nrepl (e.g. plain nREPL or Babashka) finishes setting up the connection again, where cider-connected-hook and friends never ran. proof found this one.
  • Keys with a nil value are left out of nREPL requests instead of being sent as empty lists, which closes an issue from 2020.
  • Natively compiled builds no longer have broken menu options, Emacs 31 is on CI, and the manual got per-build-tool setup tabs, the refcard and CI checks for its cross references.

cider-nrepl and Orchard

cider-nrepl releases:

  • 0.63.0 runs tests with orchard.test and streams their progress to the client, which is what CIDER’s test streaming builds on.
  • 0.63.1 was Sashko’s quick bump to compliment 0.8.2.
  • 0.64.0 added the cider/resolve-missing, cider/resolve-alias and cider/clean-ns ops behind CIDER’s ns commands (after three alphas), made the test ops always answer and send what tests print with their own responses on nREPL 1.3+, and fixed the inspector and ClojureScript tests on shadow-cljs.

Orchard releases:

  • 0.45.0 added orchard.test, the clojure.test runner that used to live in cider-nrepl and can now report progress as it goes, plus Sashko’s string analytics in the inspector and his fix for the stack overflows when printing recursive collections.
  • 0.46.0 and 0.47.0 added orchard.ns-form, which takes apart an ns form and tells what it requires, refers and imports that the code doesn’t use, all without loading anything, and fixed some Java doc rendering problems.
  • 0.48.0 made tracing show calls that throw and stopped tracing a function tracing itself uses (say, str) from overflowing the stack.

Sashko also added JDK 27 to the CI of nREPL, cider-nrepl and Orchard. Thanks, Sashko!

refactor-nrepl and clj-refactor

refactor-nrepl 3.15.0 documented the limitations of its AST-based analysis and deprecated the ops cider-nrepl has taken over. Back in the spring I said I’d like to move the most useful refactor-nrepl features to CIDER and cider-nrepl eventually, and the ns editing commands above are the first step in that direction.

clojure-ts-mode

No release, but quite a few fixes on main. I fixed clojure-ts-mode on Emacs 31 (e.g. the ClojureScript mode asking for the jsdoc grammar, M-q indenting only the innermost form) and a pile of refactoring commands that went wrong around metadata, #_ forms and reader conditionals. Jason Jackson sent a round of indentation and alignment fixes (threading macros, reader prefixes, metadata and comments before arguments), and Kyle Cesare kept up with jank’s syntax. Thanks, Jason and Kyle!

MrAnderson and smaller things

MrAnderson 0.7.2 stopped bundling pomegranate’s Maven classes in its jar, where they clashed with any other resolver on the classpath and broke cider-nrepl’s build once tools.build pulled in a newer tools.deps. The artifact went from 8 MB to about 200 KB in the process.

There was also a lot of release and CI maintenance across nREPL, cider-nrepl, Orchard, Piggieback and friends (e.g. release notes taken from the changelogs, the docs of a release published by its release workflow), and I revived the CI of parseclj and parseedn.

Blog posts

What’s next

Last time I said CIDER 2.1 was the obvious next milestone. It still is, but it got delayed, as most of my time went into nREPL instead. nREPL 1.8 shipped as planned, and mezcaml and the BEAM servers got a lot more reliable thanks to proof, even if neither got a release. While we’ve made solid progress on CIDER and friends as well, my main focus until the end of the year will likely be the broader nREPL ecosystem: fixing the interrupt bugs proof found in nREPL, turning the list of spec changes into PRs against the spec, and getting more servers and clients to run proof.

Thanks to Clojurists Together for the continued support of my Clojure OSS work! You rock!


Clojure Camp

2026 Annual Funding Report 5. Published Nov. 7, 2026.

What Happened

Workshops at Conj

  • Ran two 3-hour workshops, which were well received.
  • Prepared three self-guided “learning modules”: clojure-camp/code-jam
  • Updated and re-released Clojure Warrior (a port of Ruby Warrior): warrior.clojure.camp
  • Updated our remote mob tooling to support multiple mob servers simultaneously.

Bringing New Clojurians to Conj

  • Sponsored 5 new Clojurians to attend Conj.

Hosting an Airbnb at Conj

  • As an experiment, we organized a group stay at Conj, in preparation for doing it at other times and places.

Rewrote Our Pairing Tool

Clojure at the Recurse Center

  • I’ve been running a Clojure workshop at the beginning of every Recurse batch. It’s been successful at convincing a few people per batch to spend some of their time on Clojure.

Corporate Bookkeeping and Filing

  • Not much to say here, other than that Plain Text Accounting + Claude work well together.

Plans

  • Continue on badges site content
  • Continue on new event management tooling
  • Continue on research project on how Clojure has changed over time (just starting)
  • Ongoing events:
    • Monthly mob at Recurse
    • Weekly book club
    • Monthly remote mob

Eric Dallo

2026 Annual Funding Report 5. Published Nov. 6, 2026.

2 months of big milestones! ECA passed 1000 stars on GitHub :proud:, got an official terminal client written in Clojure and became much more resilient for long-running agents, with lots of new people contributing. On clojure-lsp, we had a new release full of community contributions, followed by a big round of refactoring fixes. Thanks ClojuristsTogether and everyone helping with feedback, issues and contributions! :heart:

ECA

ECA reached 1000 stars on GitHub in September :star:, a really special milestone for a project created a bit more than a year ago, and eca-emacs passed 500 stars too, thank you all! These 2 months were focused on reliability for long-running tasks, better support for subscriptions and the terminal experience. I’m starting to plan a stable release soon, since we already consider ECA pretty stable for some time. As usual the changelog is huge, so here are the highlights since the last report:

0.158.0 - 0.169.0

  • Subscription quota: For Claude Pro/Max, ChatGPT and GitHub Copilot subscriptions, chat usage and provider status now show the remaining quota and when it resets instead of costs, plus a new /quota command. Chat usage also shows the output tokens per second (TPS) of the responses.
  • Multiple accounts of the same provider: Use work and personal subscriptions side by side via provider inherit, like "anthropic-work": {"inherit": "anthropic"}.
  • Resumable subagents: spawn_agent can continue a previous subagent conversation keeping its history and model, and the new subagent timeoutSeconds gives subagents a final turn to report their findings on timeout or max steps instead of losing their work.
  • MCP tool search: New mcpToolSearch config keeps MCP tool schemas out of the context until the LLM loads them with the new eca__search_tools tool, a big context saver for people with lots of MCP servers.
  • view_image tool: The agent can now load png/jpg/gif/webp files from disk into the context, useful to check screenshots or generated images.
  • Chat-scoped approvals: “Approve & remember” is now scoped to the chat and its subagents instead of all chats, with new /approvals and /forget-approvals commands to list and forget them.
  • Better compaction: New autoCompactTokensLeft config to auto-compact when a fixed number of context tokens remain instead of a percentage, and a new compaction prompt for higher quality summaries.
  • Performance: New incrementalUpdates capability makes rollback, flags and forks fast on long chats, updating them incrementally instead of replaying the whole history. Native binaries are now built with GraalVM for JDK 25, fixing an idle server burning CPU when gateways close connections mid-stream.
  • More providers and models: Claude Fable 5.1, Mythos 5.1 and Opus 5.5, gpt-6-astra, gpt-6-sol, gpt-6-luna and gpt-6.1-sol, a smarter GitHub Copilot model catalog hiding models the account can’t use, no_proxy support and more.

That’s 238 commits from 19 people in the server repo alone, thanks zikajk, emecii, mikemikimike, itkonen, rschmukler and everyone else contributing with issues, PRs and discussions, love this community!

Also, Jeremiah Coyle (@paintparty), funded by ClojuristsTogether as well, started a redesign of the eca.dev docs with better typography and icons, a persistent docs sidebar and a logo wall with some of the companies using ECA, like Nubank, Flexiana, Biolit and Team Ohana, a really amazing work so far!

ECA CLI

eca-cli

The terminal client got a huge push and is now an official ECA client! It’s written in Clojure with Babashka and charm.clj, and ships as a single self-contained binary that downloads and updates the ECA server by itself, no Babashka, JVM or editor needed. It has most of the features of the editor clients: markdown rendering with syntax highlighting, tool approvals with diffs, questions, steering, task panel, rollback, flags and forks, @ contexts, provider login and MCP management, plus a print mode for scripts and pipes, like git diff | eca-cli -p "review this change". Thanks to Sam O’Leary for starting the project, it’s really nice to see a Clojure TUI like this one!

Other ECA clients

Most clients got the subscription quota and TPS in the chat usage, the subagent model and variant in the spawn_agent card and lots of fixes, plus client specific work:

  • eca-emacs: Faster rendering of long chats with incremental updates and coalesced streaming, inline thumbnails for images mentioned in the chat, file buffers reverted after ECA edits them, TRAMP hangs fixed, and lots of UX fixes for evil, Doom and Emacs 31.
  • eca-vscode (0.54.1 - 0.56.0): New eca.sendProcessId setting and starting the server from the workspace dir, making it easier to run ECA inside a sandbox, plus a fix for copying chat messages in VS Code 1.143.
  • eca-intellij (0.31.1 - 0.33.0): Fix chat panel freezing on /login and slow server replies, and high CPU while long tool calls stream.
  • eca-desktop (0.10.1 - 0.12.0) and eca-web: Shared webview improvements, like proper markdown lists, and pending questions restored when opening remote chats.
  • eca-nvim: Reload open buffers when the server edits a file shown in the sidebar.

The community also built 2 new clients that are now part of the org: eca-omarchy, a native client for the Omarchy shell by Oliver Marks, and eca-pharo, an ECA integration for Pharo Smalltalk by Larry Staton Jr., showing how the ECA protocol makes it easy to bring the same experience anywhere!

clojure-lsp

A new release is out with the changes mentioned in the last report, plus lots of community contributions: new code actions, better :refer suggestions, hover for special forms and more. We also had eoliphan reporting a batch of 9 detailed bugs in refactorings like move-form, clean-ns, promote-fn and extract-to-def, all fixed and already on master for the next release, thanks!

2026.10.15-11.42.38

Besides the changes from the last report, like the cycle-namespaced-map code action and the API/CLI rename fix:

  • New code action to replace a fully-qualified namespace with its alias, adding the :as to the :require form when missing. #1810
  • Multi-file refactorings now prompt for approval, if supported by the client. #2370
  • Sort namespace suggestions by usage when suggesting :refer. #1997
  • Show built-in documentation when hovering Clojure special forms. #2457
  • Report the processing of external file changes (workspace/didChangeWatchedFiles) as work done progress, ending only once dependent files are re-analyzed, so clients can wait for those changes to be fully processed. #2473
  • Fix the “Fetching libs for completion” progress never showing up in clients.
  • Fix navigation and other features inside dependency jars when clients send single-slash jar URIs, including escaped Calva URIs. #2477
  • Prevent Java test fixtures from being written to the global JDK cache, and rebuild caches missing analysis or checksums. #2285
  • Fix :cache-path setting being ignored. #2475
  • Skip deleted files in CLI clean-ns and format. #2470
  • Bump GraalVM to 21 for the Windows native binary, in sync with the other archs.
  • Bump clj-kondo to 2026.08.05-20261014.211514-15.

clojure-lsp-intellij

3.5.6

  • Fix IDE freeze while typing with the completion popup open: LSP4IJ was waiting for completionItem/resolve on the UI thread, now the item is resolved in the background and the completion documentation is computed asynchronously.

metrepl

0.5.3

  • Replace jsonista with charred, dropping the jackson-core dependency.

Jeaye Wilkerson

2026 Annual Funding Report 5. Published Oct. 27, 2026.

Hello everyone! Thank you so much for sponsoring me this year. It’s been an incredible year for jank. As I mentioned at Clojure Conj 2026, the next milestone for jank is a beta release. I’m targeting January 2027. In this post, I’d like to spell out exactly what that release will entail and how we’ll get there. The beta release has four priorities, in no particular order:

  • Stability
  • Portability
  • Parity
  • Usability

The progress of all of these is being tracked by this Github milestone.

Stability

jank still has many bugs and we’re not going to catch them all prior to the beta release, but we do want to catch the most obvious and impactful bugs. Over the past couple of years, we’ve added a ton of new functionality to jank and we need to ensure it works reliably. We have great test coverage for most systems, but we still need to fill in the gaps and address the systems which are lacking coverage. The main targets here are:

Core Clojure libs (as tested by jank’s clojure-test-suite – help wanted!) Various C++ interop edge cases nREPL and native build system tooling

Portability

We have jank packages for macOS Homebrew (arm64), Ubuntu (x86_64), Arch (x86_64), and Nix (x86_64). Each of these packages is bleeding edge, since jank doesn’t yet have versioned releases. The biggest change here for beta will be that jank releases will become versioned and immutable. The existing bleeding ege jank packages will be renamed to jank-dev and the default jank packages will track versioned releases. Releases will happen at whatever cadence feels right.

I’d like to grow the package area to include Homebrew packages for Linux x86_64 and macOS x86_64, as well as statically linked Alpine packages and Windows x86_64 MSYS2. If you have an OS where you’d like to see jank, let me know!

Part of portability is to improve jank’s embedding story. This involves filling out jank’s C API, improving error handling, and addressing the common gotchas around embedding an LLVM-based JIT runtime.

Parity

Some areas of Clojure parity, such as protocols and records, have yet to be tackled. We’re also missing a couple of dozen key vars in clojure.core, like rseq, unchecked arithmetic, etc. For the beta release, I want to achieve a strong parity with upstream Clojure JVM and track it in terms of Clojure’s versioned releases. We should be able to say that a particular version of jank tracks a particular version of Clojure JVM.

On the C++ interop side, we need more parity with how we handle native arrays, pointer decays, and initializer lists. I also want to improve our feature set for C++ interop to allow for defining statically typed C++ functions, globals, and structs within jank code.

Usability

As part of the beta release, the jank website will be updated to include starter examples for game development, GUI apps, and terminal apps. Part of this will include more packaged native libraries in awesome-jank. I will also focus more on implementing guides and references for Clojure devs to onboard into jank land, understand the interop, jank’s type system, and start building an intuition for how jank’s host systems work.

Performance

It’s intentionally not a goal to improve jank’s performance for the beta release. There are plenty of areas in jank to optimize and plenty of benchmarks where jank will not do favorably against Clojure JVM, but I will be focusing on those next year, leading up to jank’s gamma release. Don’t worry! When I do focus on performance, we’re going to make some huge gains. I’m already prepared for many of them, but they’re simply not a priority right now compared to everything else.

How’s the progress so far?

If you check out the milestone as of writing this, you’ll see that we’re around 50% of the way through already. Thanks to the help of Chris Badahdah, of Portal fame, we’ve been melting through the bug list at quite a good clip. I’m expecting, and hoping, that we’ll get more bug reports in the coming weeks, thanks to the Lisp Game Jam. That’s exactly where folks like you come in!

Summary

So, in summary, the next milestone is a beta release, targeted for January 2027. We’re focusing on portability, stability, parity, and usability. We need all of you to try jank out and report issues in order to do a great job of that. Find any excuse you can to kick the tires and please file a Github issue if the tires fall off!


Michiel Borkent

2026 Annual Funding Report 5. Published Nov. 8, 2026.

Title: OSS updates September and October 2026 Date: 2026-10-30 Tags: clojure, oss updates Description: My Clojure OSS updates for September and October 2026

In this post I’ll give updates about open source I worked on during September and October 2026.

To see previous OSS updates, go here.

Sponsors

I’d like to thank all the sponsors and contributors who make this work possible. Without you, the projects below would not be as mature or would not exist or be maintained at all! So a sincere thank you to everyone who contributes to the sustainability of these projects.

gratitude

Current top tier sponsors:

Open the details section for more info about sponsoring.

Sponsor info

If you want to ensure that the projects I work on are sustainably maintained, you can sponsor this work in the following ways. If you work for a company that uses my OSS, please ask your employer, that would be even better. Thank you!

Intro

A lot happened again in the last two months! One major highlight was my visit to the Clojure Conj together with Rahul Dé, where we organized a Babashka workshop. It was great seeing lots of familiar and new faces over there. To give people at the workshop the best experience possible, lots of improvements went into babashka prior to the Conj… workshop driven development I guess. The major milestones I focused on during the last 6-8 months were:

  • Automatic command line help and completions in babashka tasks via babashka.cli
  • Resolving dependencies without a JVM (by vendoring tools.deps and rewriting the maven part)
  • Babashka FFI: a foreign function library to call C-ABI functions from libraries
  • Better nREPL support: support popular nREPL middleware like CIDER’s test middleware, the CIDER inspector, etc
  • TUI support via JLine

All of this combined made for a great workshop, where we developed a stateless password manager that used cryptography either by shelling out or by FFI. This project combined the most-used libraries in babashka, like babashka.fs, babashka.process, babashka.cli, babashka.ffi, etc. so even if you didn’t end up using this password manager (most people probably didn’t) you certainly were exposed to what you would typically use in a bb script or project. Rahul and I will give this workshop at EuroClojure 2027 as well, so in case you missed it, you’ll get one more chance!

image

Babashka workshop at Clojure/conj 2026, seen from the back of the room.


image

Rich Hickey and Rahul Dé getting his hamster hammock signed at Clojure/conj 2026.

Highlights

Here are some personal favorite highlights from the last two months.

  • Squint

  • Antq, a tool to update outdated deps, now runs with bb!

  • You can now run build.clj in babashka with tools.build (which comes bundled with bb) and get --help and shell completions for your build tasks. Sean Corfield has been adopting this in projects he maintains. See for example: next.jdbc, HoneySQL, logging4j2 and rephrase

  • Babashka.ffi

    • First Clojars release on 2026-09-18, for the JVM, native image, CLJS and nbb. Node.js 26.1+ through node:ffi
    • JVM performance PRs by @Ramblurr (#36, #37)
    • Adopted by sqlite4clj, which moved from coffi to babashka.ffi (#26, @Ramblurr)
  • Clj-kondo

    • Type system improvements: (when (and (string? x) (pos? n)) (inc x)) will now warn since (string? x) implies x is a string and (inc x) will crash on that input
    • :unknown-map-key linter that warns when requesting keys from maps that provably aren’t there: given (defn opts [] {:host "localhost"}), (:hots (opts)) will warn.

Blog posts

Blog posts that I published in this period:

Updates

Here are some highlights per project. See each project’s CHANGELOG.md for the full list.

  • Babashka: native, fast-starting Clojure interpreter for scripting.

    • Resolve dependencies without a JVM by default. Set :deps-resolver :jvm in bb.edn or the add-deps map, or BABASHKA_DEPS_RESOLVER=jvm, to use Java as before. See the blog post
    • Make clojure.tools.deps and its script namespaces, including clojure.tools.deps.script.make-classpath2, available to scripts
    • Bundle clojure.tools.build.api, with patched install and javac for babashka. A tools.build dependency on the classpath takes precedence and runs from source
    • Add bb repl --connect [addr] to connect to an nREPL server with completion, eldoc, documentation lookup and Ctrl-C interruption. Accepts host:port, a port or unix://path. Reads .nrepl-port by default.
    • Bind the nREPL server to 127.0.0.1 by default instead of 0.0.0.0. Use bb --nrepl-server 0.0.0.0:1667 in Docker
    • Bundle nREPL ops for the CIDER inspector, test runner (C-c C-t) and cider-version
    • Add the nREPL interrupt op. Interruption is cooperative and does not stop tight loops
    • Smaller binary: the bundled Clojure sources are stored gzipped, and jline’s class files are no longer embedded a second time as resources.
    • Add the CIDER debugger to the nREPL server: #dbg, #break, C-u C-M-x and the step commands
    • Add CIDER’s track-state to the nREPL server: font-lock of loaded vars and the box around instrumented functions
    • Add babashka.deps.maven with find-versions, with-repository-credentials, active-proxy and model-repos
    • Block the certificates of the eMudhra intermediate CA compromise (@stathissideris)
    • The macOS amd64 binary requires macOS 11.0 or later
    • Full changelog
  • babashka.ffi: call C functions in shared libraries from Clojure.

    • 0.1.1: First standalone release of babashka.ffi, available on Clojars for JVM Clojure, ClojureScript and nbb
    • Call C libraries from JVM Clojure on JDK 25 or newer, or from applications compiled with GraalVM 25 or newer. ClojureScript, nbb and shadow-cljs can call C libraries on Node.js 26.1 or newer through node:ffi
    • #63: callback accepts struct layouts as argument and return types
    • JVM call performance by @Ramblurr (#36, #37)
    • New examples: JavaScriptCore, pinball, SDL3, libusb and QuickJS
  • babashka.esbuild: use esbuild from babashka and the JVM

    • New project. Bundle and transform JavaScript, TypeScript, JSX and CSS through babashka.ffi, without Node.js
    • Expose esbuild’s metafile and metafile analysis (#3, @lread)
    • Used by cljdoc and hyper
  • deps-deploy: deploy jars to Clojars and Maven Central from babashka

    • New project. Takes the options of slipset/deps-deploy, with transfers through babashka.http-client
    • GPG signing, local install, snapshots and several jars in one deploy
    • Maven Central through the Publisher Portal
  • SCI: Configurable Clojure/Script interpreter suitable for scripting

    • Clojure 1.13 map destructuring as of clojure/clojure 33fb064d (1.13.0-alpha11): :req!, :excess and :missing directives, :defaults removed. Adds the selector macro to clojure.core.
    • Speed up binding (about 4x on the JVM, 2x in native bb) and try without a catch (about 4x)
    • ns-map is 6x faster and ns-refers 60x
    • Fix #1094: a call to a multi-arity fn created a new fn for the called arity on every call, 25x slower than a single-arity call in CLJS
    • CLJS: creating a closure inside a jitted fn is 4x to 9x faster, and creating a multi-arity fn compiles instead of escaping to the interpreter
    • Fix instance? on a protocol returning true for a type the protocol is extended to, such as nil or Object, on the JVM
    • Full changelog
  • clj-kondo: static analyzer and linter for Clojure code that sparks joy.

    • NEW linter :unknown-map-key: warn on a keyword lookup of a key not present in a map. E.g. (:hots (opts)) will warn when opts returns {:host "localhost"}.
    • Catch up with Clojure 1.13.0-alpha9 destructuring
    • Type checker: a type predicate narrows the type of a local in if-not, cond and and. E.g. (when (and (string? x) (pos? n)) (inc x)) will warn.
    • Type checker: merge and merge-with require a map or nil as first map argument. E.g. (merge [1 2] [3 4]) will warn.
    • #2774: :warn-on-reflection also warns on Java constructor calls. E.g. (Date. x) will warn.
    • #3006, #3014, #3015, #3016: fixes for hooks generated with :clj-kondo/macroexpand-hook: referred macros, helper namespaces, requires without alias and nested hooks (@imrekoszo)
    • #2945: support :order-by, :having and :timeout in Datalevin and Datahike queries
    • #2998: analyze Java 25 class files (@skylightis666)
    • Full changelog
  • Squint: CLJS syntax to JS compiler

    • #453: Add source maps for compiled files
    • #594: support for await in doseq, with ^:await on a binding, (doseq [^:await x xs] ...), or on the binding vector for every binding (#1053, @shaunlebron)
    • Add eduction, implements? and keyword-identical?
    • Optimize compiled output for or, and and reify
    • Optimize compiled output for core predicates such as even? and string? in if, and, or and not
    • #1032: #html: escape attribute values from {:& m} (@mskyan)
    • Playground: Implement draggable resizing of editor and compile panes (#1045, @paintparty)
    • Playground: Support codemirror dark theme across editor, repl output, and compiled code instances (#1045, @paintparty)
    • Full changelog
  • neil: A CLI to add common aliases and features to deps.edn-based projects

    • 0.3.71: neil requires babashka 1.13.229 or later
    • #254: auto-generated CLI help and shell completions via babashka.cli. neil dep --help no longer throws
    • #272: make babashka.neil/-main easier to call from code (@lread)
    • #268: neil dep upgrade LIB upgrades only LIB instead of all deps
    • #267: neil dep add, dep upgrade and dep versions find versions in the :mvn/repos of deps.edn. dep versions no longer lists snapshots
  • babashka CLI: Turn Clojure functions into CLIs!

    • #226: use metadata and docstrings from :fn and :exec-fn vars in babashka and ClojureDart
    • dispatch: add :supplied to the opts map’s :org.babashka/cli metadata with the set of option keys given on the command line
    • #233: Generated help no longer prints extra spaces when :prog is not specified (@lread)
    • Full changelog
  • nbb: Scripting in Clojure on Node.js using SCI

    • Add babashka.ffi built-in library for calling C functions. Requires Node.js 26.1 or newer
    • Bump SCI: faster multi-arity function dispatch, bumps edamame to 1.6.44
  • Edamame: configurable EDN and Clojure parser with location metadata and more

    • Performance: speed up parsing plain decimal numbers on the JVM #155
    • Performance: build map literals with transients #159
    • Require Clojure 1.10 or newer #158
  • deps.clj: a faithful port of the Clojure CLI Bash script to Clojure

    • Allow --version, --help, -Sdescribe and a cached -Spath to run without Java.
    • Add *make-classpath-fn* to allow in-process classpath resolution.
    • Catch up with Clojure CLI 1.12.6.1673
  • rewrite-edn: Utility lib on top of rewrite-clj

    • #39: Support namespaced maps like #:a{:x 1}
    • neil#239: Read and update maps with metadata like ^{:x 1} {:a 1}
    • Fix get, assoc, update and dissoc when a map value equals the key
  • http-client: HTTP client for Clojure and babashka

    • #86: uri-with-query no longer decodes the existing query, fragment and user info when :query-params are used (@dylanpulver)
    • #91: decode-body passes a nil body through, e.g. of a 407 to CONNECT
  • fs: file system utility library for Clojure

    • #284: non-recursive match and glob now return immediate child directories, not only files
    • #269: match and glob no longer drop pattern text after a 2nd colon on Unix (@marksto)
    • #279: minimum supported version of Clojure is now 1.11 (@lread)
  • html: HTML generation library inspired by squint’s HTML tag

    • Omit attributes whose value is nil, or false in html mode: [:input {:disabled false}] rendered disabled="false", which disables the input. Matches hiccup (#19, @telekid)
    • Render true and false as "true" and "false" for aria-*, data-*, contenteditable, draggable, focusable and spellcheck. Names match in any case, so :spellCheck works as well
  • quickblog: lightweight static blog engine for Clojure and babashka

    • Add :pre-process-markdown-fn and :post-process-html-fn options to transform each post’s Markdown and HTML (README) (@jmglov)
  • gh-release-artifact: Upload artifacts to GitHub releases idempotently

    • Throw when Github does not accept an upload. A failed upload no longer leaves a green build
    • Upload the new bytes before touching the existing asset when replacing one
  • Buzz: write a web application with the JVM or babashka only

    • New observe, atom-source and buzz.source/Source API. :watch and invalidate! are gone (#20)
  • raylib-bb-ffi: raylib bindings for babashka via babashka.ffi

    • New experiment, ported with an LLM. Tests whether the examples of raylib-jlt and raylib-clj run in babashka and on the JVM through babashka.ffi
  • cljbang: a Clojure-like language that runs as Emacs Lisp

    • Add for (#12) and juxt (#9, @kpassapk)
    • Fix #10: str/join with a separator and an empty coll returns ""
  • Cherry: Experimental ClojureScript to ES6 module compiler

    • Bump squint: don’t crash the nREPL server on a SyntaxError from eval (#208)
  • Cream: Clojure + GraalVM Crema native binary

    • Compare load times, tooling and GraalVM build with babashka (#7)
  • lein-clj-kondo and clj-kondo-bb: released alongside each clj-kondo release

Contributions to third party projects:

  • antq: antq runs on babashka (#316, #380) and is installable through bbin (#386)
  • hyper: compile h/expr against a served squint core, tree-shaken with esbuild. See the blog post. Serve source maps for defc components behind :source-map?
  • nextjournal/markdown: #71: render the link title in the default :link renderer
  • clojure-mode: bind Option+S to unwrap on macOS (#62)

Other projects

These are some other projects I’m involved with, but little to no activity happened in the past two months.

Click for more details

Rendering in batches with glimmer and Datastar

Anders Murphy recently wrote about faster streaming HTML with batches and aggregates. The idea is simple. When the server streams HTML to a page, it should collect the changes and render once for the whole batch. Similarly, it makes sense to avoid rendering the same thing twice for two different pages to avoid resending the data that the client already has.

I've been building glimmer and glimmer-datastar to do reactive server side rendering for jolt, and ran into similar problems. This post covers how both libraries have been reworked to facilitate batch processing, and how these changes are used in the newsroom app.

The setup

Glimmer is a Reagent-style library where state lives in ratoms which UI components subscribe to. The original target was native GUIs such as glimmer-gtk and glimmer-tui, but the reactive core is toolkit agnostic. So, naturally I got curious if I could use the same model on the server with Datastar on the frontend.

Read moreShow less

The way this works is that each open page keeps a Server-Sent Events stream to the server. When the page is rendered, any ratom the page reads is recorded, and the stream subscribes to it. Then whenever one of those ratoms changes, that causes the stream to render the page again. So, when a swap! happens on a ratom, that sends a datastar-patch-elements event with the new HTML to every page that renders it.

(def state (ratom/atom {:greetings []}))

(def handler
  (-> (fn [_] {:status 200 :body (render-page @state)})
      (datastar/wrap-datastar {:tick-ms 15})))

The first version did the obvious thing which is to wake up every stream that reads the ratom on a write to do a render. The naive approach works fine with a few pages that do occasional writes, but it doesn't scale with load because the cost ends up being writes * pages.

Render once per tick, not once per write

The fix here is to decouple writes from render requests. A write only needs to mark the streams that read the ratom as dirty, then a separate render loop can run on a fixed timer specified by :tick-ms. And every dirty stream ends up being rendered exactly once each time it wakes up with all the changes that have been batched up since the last time:

(defn mark-dirty! [sched conn]
  (swap! (:dirty sched) conj conn)
  (wake! sched))

(defn- tick! [{:keys [txs dirty batch-fn] :as sched}]
  (run-batch! batch-fn (drain txs))
  ;; snapshot after the writes, so the connections they dirtied render now
  (let [[conns _] (swap-vals! dirty (constantly #{}))]
    (render-all! sched (vec conns))))

The loop sleeps out whatever is left of the tick before rendering, allowing every change that arrives to land in the same batch. Meanwhile, an idle loop simply parks on a channel without incurring any additional cost.

The effect on a burst with ten open streams and a hundred swap!s in a row is fairly dramatic as one would expect:

rendersframes sent
a wakeup per write100055
render ticks2020

Before, the server had to do a thousand renders, most of which were thrown away. With ticks in place, the render count depends on the number of pages and how long the burst lasts rather than how many writes it contains. Incidentally, glimmer's GUI side already worked this way since a component's watcher holds a pending flag to ensure that a burst of changes queues a single deferred render on the UI thread.

Slow clients drop frames instead of queueing them

A tick produces at most one frame per stream, and each stream sends its frames through a channel with a sliding buffer of one. If a client hasn't taken the last frame by the time the next one is ready, the old frame is discarded so that the client always gets the latest update. This way a bad connection can't back up the render loop or build up an unbounded queue of stale HTML.

Batched writes with tx!

Since ticks batch renders, it makes sense for writes to be batched as well, as an opt-in. This is done using tx! to queue a thunk that runs on the loop thread at the start of the next tick, and before a render happens:

(fn [request]
  (datastar/tx! request #(swap! state update :greetings conj "hello"))
  {:status 204})

Writes and renders have to take turns, so that a render can't observe a partial update. If you pass a :batch-fn, it receives all of a tick's thunks together, so you can run them in a transaction, and calling tx! returns a promise of the thunk's result.

Using component cache to avoid rendering the same thing twice

While ticks cut renders per page, there is also waste across different pages to consider. If a hundred people are looking at the same 300-row board, rendering it a hundred times per tick produces a hundred identical strings.

To address that, glimmer-datastar provides its own hiccup renderer in jolt.datastar.html. The head of the vector is a component of [row item], and its HTML is cached under the vector itself, so the key is the function plus its arguments. The whole board can now render once per change with every page showing it sharing the result.

As always, the tricky part is knowing when a cached entry is stale, since components read ratoms as well as their arguments, glimmer now has capture for this case:

(let [a (atom 1) b (atom 2)]
  (r/capture #(+ @a @b)))   ; => [3 [a b]]

It runs a function and returns its result along with every reactive cell the function read. The cache stores each entry with those cells and the values it saw in them, and once any of those values changes, that indicates a need for a cache refresh. And on a hit, the cache simply reads the cells again.

The cache also admits an entry only when it's asked for more than once, so components that render once and never again don't push out ones that render every frame. It evicts by generation, which is roughly LRU without needing per-entry bookkeeping on the hit path.

On a 4-core machine, with 100 streams showing the 300-row board and one row changing per tick, a tick takes:

render-threads1248
no component cache891 ms472 ms481 ms480 ms
component cache68 ms55 ms55 ms47 ms

The cache is the big win here while render threads level off, so two is the default.

And capture also fixes another problem unrelated to speed. Since a stream now knows exactly which cells its last render read, it only needs to watch those. A cell it stopped reading can be unwatched right away, rather than leaving a subscription behind that triggers extra renders later.

Not sending what the client already has: patches

With rendering no longer being the bottleneck, the wire becomes the main cost since a single row change to that 300-row board still sends a 40 KB page. One obvious solution here is to enable streaming compression which gets you part of the way. Using ring-chez-adapter's wrap-gzip compresses an SSE stream with a single deflate window across frames, so a frame that's almost identical to the previous one compresses to almost nothing. But that holds only while a frame fits in deflate's 32 KB window. Past that the saving mostly disappears, and even a one-row change costs 4316 gzipped bytes.

So, we need to use patches to address the underlying problem. A component whose root element has an id, like [:tr {:id ...}], is a part of the page. Since each stream compares its new page with the last one, part by part, it only needs to send parts that changed. Then, on the frontend, Datastar swaps each one in by id. When the page changes around its parts, such as a row being added, text outside every part being edited, or the client missing a frame, the whole page still gets sent.

rowsgzip stream, per changepatch, gzip stream
5092 bytes28 bytes
3004316 bytes35 bytes

Since the size of a patch depends on the change rather than the page, the comparison is cheap too. A cached component brings its parts as the same shared vector on every page, so the diff can skip it with an identity check. On my machine, this roughly halved the time for each tick going from 21.7 ms with whole frames to just 7.0 ms with patches. Using the delta is key for incremental view maintenance because only parts of the page that change have to be sent as updates.

Aggregates: derived state that follows the change

We're not done yet though, because another trick that can be applied is to use aggregates. Take a reaction like (reaction (count (filter open? @todos))). It recomputes from scratch on every write, so whether a single row or a thousand change, the reaction still costs a thousand.

Glimmer's new glimmer.table borrows from DBSP. A table is a keyed collection cell, and a view over it maps or filters only the rows that changed. An aggregate whose operation has an inverse, like a count or a sum, simply takes the old row out and puts the new one in:

(def todos (t/table {1 {:title "a" :done false}}))
(def open (t/view todos (fn [_ v] (when-not (:done v) v))))
(def open-count (t/aggregate open t/count-of))

(t/put! todos 2 {:title "b" :done true})   ; costs one row, not the table

These are ordinary reactive cells, so pages subscribe to them with @ like anything else. With ticks, the cache and patches, a write costs one row to derive, there is now one component to render, and one part to send.

Letting go of closed pages

Of course, batching only helps if you're not doing work for pages nobody is looking at. So, when a client goes away, the server closes the stream's channel. A transducer on that channel runs on close and unwatches every cell the stream was tracking, making it possible to garbage collect the connection right away. And a render that the handler answers with a redirect or an error, such as a 401 after sign-out, cleans up the stream the same way.

What does this look like in practice

I've always been interested in understanding how world news and events all connect to each other. There are many different things happening on any given day, and things that often seem like isolated events can often have shared underlying causes.

Decision makers get fancy daily briefings prepared for them by analysts who review the events to produce a perfectly prepared report. So, I figured why can’t I have something like that too?

And so, I built Newsroom which is a self-hosted news desk app to gather sources and write a daily briefing tailored to what I'm interested in. The app goes through hundreds of sources using a combination of RSS and web scraping. It then groups the news into categories and gets an LLM to build a graph of events and their connections to provide a basis for the report.

It was a perfect project to exercise the library since it has a live sidebar showing the progress as each source is aggregated, and the model's progress as it streams.

That kind of workload is the worst case for one-render-per-write. A run fetches its sources concurrently from several threads, and each source reports a result and a log line. Meanwhile, the model's progress updates many times a second while it streams. Every one of these events used to be a swap! on the run's status ratom, and every swap!, in turn, translated into a render of every page watching that reader's run.

Now, the server asks for {:tick-ms 200} which prevents event flooding. However fast the status changes, an open page still renders at most five times a second, and a burst of sources finishing together all get packaged into a single frame. The whole change was just one line, since newsroom already asked for the same 200 ms through the old :rate-limit-ms option. What changed underneath though is that a tick now coalesces the writes instead of only spacing them out.

Ticks can also split two related writes across frames to ensure correct order. A source used to set its state and then append its log line in a second swap!, so a page could show "ok, 12 items" in the source list before the log mentioned it. Now both go in one write:

(update-status! (:run-id ctx)
                (fn [st]
                  (-> st
                      (update-in [:sources name] merge {:state :ok :count (count items)})
                      (add-event {:text (str name ": " (count items) " items") :level :ok}))))

That's the same idea tx! enforces, done by hand where one function owns both writes. It also depends on glimmer's swap! being atomic, because sources call this from several threads at once.

Each newsroom page has two live regions, the sidebar and the article, both running on separate streams. The sidebar reads the run status, which changes constantly during a run. The article reads only a counter bumped when a day is stored. Since a stream only watches what its last render read, the article doesn't need to repaint while a run is in progress.

All readers have their own lanes in the form of a status ratom and a stored counter, so each reader's run only dirties their own pages. When a tab closes, or a reader signs out and the stream gets its 401, the stream drops its subscriptions.

Incidentally, the same kind of batching also works for the database. Making the status of a run durable makes it possible to pick up where it was interrupted. But the same problem we talked about earlier came back from the other direction. Writing the status to SQLite on every swap! results in a database write per progress tick. So, Newsroom marks the run as unsaved instead, and a background thread writes the changed runs once a second:

(defn- update-status! [run-id f & args]
  (swap! (status-of run-id) (fn [st] (if (identical? run-id (:run st)) (apply f st args) st)))
  (when (and (instance? Run run-id) (.-job ^Run run-id))
    (swap! unsaved conj run-id)
    @saver))

This is the same tick pattern of marking the state dirty, and doing a flush on a timer. A restart loses at most a second of the log without the worry of having IO pressure. And the final state can be written synchronously when the run ends.

Summary

Each layer that I've discussed in this post removes a different kind of repeated work:

  • ticks ensure that one render happens per page per tick rather than per write
  • the component cache: further gives one render per change on each page
  • sliding buffers give one frame in flight per client
  • tx! makes it so that a render can't see a partial update
  • patches send the change only for the page components that changed
  • tables derive state from the change instead of the collection itself
  • release on close cleans up pages nobody is looking at

The reactive model is key for all of this, because the dependency tracking used to decide what to re-render can also be used to infer what can be skipped.

State of Clojure 2026 Survey

It’s time for the annual State of Clojure Survey!

If you use Clojure, ClojureScript, or any other Clojure dialect, we would love to get your responses to the following survey:

This survey has been running since 2010 and serves as the most useful barometer we have of what the Clojure community is thinking about and using day to day. It just takes a few minutes (most questions are optional, feel free to skip) but really is an important contribution.

The survey will close Nov 30 so that we can release the results towards the end of the year. Thanks!

Scittlecastr: All You Need is Persistence

Teenager writing HTML on chalk board

This post was entirely written by a human, with no assistance from LLMs or other "AI" technology such as pervert glasses or autonomous weapon systems.

Last time on Scittlecastr: The Scittling, we scittled up a podcast player, using the awesome power of the ClojureScript REPL.

What we found great about this approach was the ability to create HTML elements on the fly and update the page without having to reload, but at the end of our session, we were struck by the fear that if we do reload, we will lose all the state of our running program.

Before we grit our teeth and test our hypothesis, I present to you a

Table of contents

  1. Shoot outside of the screen to reload - writing functions to get the tag name and attributes of DOM elements
  2. Data is elemental - turning DOM nodes into Clojure maps
  3. The resurrection - rebuilding the podcast player in the REPL
  4. It's an unstable era - starting to turn the DOM into HTML
  5. Textual healing - handling text nodes
  6. HTML at last - finally transforming the entire DOM into HTML
  7. Plausible deniability - filtering out DOM elements added by extensions
  8. Indenting for fun and profit - indenting generated HTML nicely
  9. Shaving that final yak - saving the HTML to a file
  10. Button mashing - making the buttons work on page reload
  11. Atomic elbow - introducing state
  12. What has CSS ever done for us anyway? - generating CSS from inline styles
  13. It’s your birthday. Someone gives you a calfskin wallet. - grouping styles by class
  14. Inline skating away on the thin ice of a new day - switching from inline styles to an external stylesheet
Read moreShow less

Shoot outside of the screen to reload

OK, with that housekeeping out of the way, let's screw our courage to the sticking point and reload the page:

Blank webpage

😭😭😭

The Buddha spake wisely when he claimed that life is a vale of tears. However, we have achieved satori through Scittle, so we discard our fragile ego and expand our consciousness to encompass all things, and discover wisdom in the infinite.

This wisdom was that of having done our interactive development in a Rich comment, thus allowing us build our interface back up by evaluating our player.cljs buffer, then stepping through the Rich comment, evaluating each form one by one (you can use cider-eval-last-sexp (C-c C-e) for this, since you don't need the comment inserted again). Let's rebuild our initial ugly player, using our slick new functions.

Make sure to evaluate the entire buffer after a page reload (C-c C-k), or you'll get an error similar to this the first time you try to evaluate a form in the REPL:

(comment

  ;; ...

  (get-el "body")
  ;; => "#error {:message \"Unable to resolve symbol: get-el\",
  ;;             :data {:type :sci/error, :line 1, :column 1, ...}}

  )

Having evaluated the buffer, let's get ugly!

(comment

  ;; ...

  (set-children! "body"
                 [(create-el {:tag "h1"
                              :classes ["cljcastr"]
                              :text "cljcastr player"})
                  (create-el {:tag "audio"
                              :classes ["cljcastr"]
                              :attrs {:controls true
                                      :src "https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3"}})])
  ;; => #object[HTMLBodyElement [object HTMLBodyElement]]

  )

That is quite a pain, though. Is there a way to effectively export the running program as HTML so we could save it to our index.html? Well, let's give it a go.

What we'd like is to turn the body DOM node into an HTML fragment. We can actually already do this in a clunky way. We log the element:

(comment

  ;; ...

  (-> (get-el "body")
      log)
  ;; => #object[HTMLBodyElement [object HTMLBodyElement]]

  )

and then look at the console:

JavaScript console with body element and children

Unfortunately, we can't directly use the console output in the REPL. We could of course just copy and paste it from the console window, but that feels too efficient, and we'd rather spend some time shaving those yaks, so let's think about how we might generate our own HTML from the DOM tree anchored at the body element.

First, we need to do a depth-first traversal of the children of body. This is quite easy for the time being, since body only has children and no grandchildren or great-grandchildren or whatever. Let's try to count those children:

(comment

  ;; ...

  (def children (-> (get-children "body")
                    log))
  ;; => #'cljcastr.player/children

  (count children)
  ;; => 3

  )

Wait, what's going on here? The JS console actually holds the key. If we look in the console at the previously logged line and expand it, we see:

  ▼ (2) [h1.cljcastr, audio.cljcastr, protonpass-root-64f9] ℹ        nrepl.cljs:10
    ‣ 0: h1.cljcastr
    ‣ 1: audio.cljcastr
    ‣ 2: protonpass-root-64f9
      length: 2
    ‣ [[Prototype]]: Array(0)

Looks like my Proton Pass extension is adding an element to the body. 😬

But luckily, we added the cljcastr class to all our elements, so we can use a CSS selector to limit the children to the ones we actually put there ourselves:

(comment

  ;; ...

  (def children (-> (js/document.querySelectorAll "body > .cljcastr")
                    seq
                    log))
  ;; => #'cljcastr.player/children

  (count children)
  ;; => 2

  )

Excellent! Now let's see what the element names of the children are:

(comment

  ;; ...

  (->> children
       (map get-name)
       log)
  ;; => ("h1" "audio")

  )

And their attributes?

(comment

  ;; ...
  
  (->> children
       (map #(.-attributes %))
       log)
  ;; => (#object[NamedNodeMap [object NamedNodeMap]]
  ;;     #object[NamedNodeMap [object NamedNodeMap]])

  )

Ah yes, thank you for not using a POJSO (Plain 'Ol JavaScript Object, ofc) there. I guess we'll need some sort of function to turn a NamedNodeMap into a Clojure map. No matter, we can write some simple Clojure to fix this:

(comment

  ;; ...
  
  (->> children
       (map (fn [el]
              (let [attrs (.-attributes el)]
                (->> (range (.-length attrs))
                     (map #(let [attr (.item attrs %)]
                             [(-> (.-name attr) keyword) (.-value attr)]))
                     (into {})))))
       log)
  ;; => ({:class "cljcastr"}
  ;;     {:controls "true",
  ;;      :src "https://media.zencastr.com/audio-files/...",
  ;;      :class "cljcastr"})

  )

Um. This is... yeah. I think it's time to write a few functions to decomplect this mess. Let's head back up to the top of the file and add a few little functions:

(ns cljcastr.player)

;; ...
;; (defn get-attr ...)

(defn attr->clj
  "Given an entry in an attribute map (i.e. `Attr` in a `NamedNodeMap` that is
   the value of the `.-attributes` property of an element), returns a vector of
   the name (as a keyword) and value of the attribute."
  [attr]
  [(-> (.-name attr) keyword) (.-value attr)])

(defn get-attrs
  "Returns the attributes of an element as a map of attribute names (as keywords)
   to attribute values."
  [el]
  (let [el (get-el el)
        attrs (.-attributes el)]
    (->> (range (.-length attrs))
         (map #(->> % (.item attrs) attr->clj))
         (into {}))))

;; (defn set-attr! ...)
;; ...

(comment

  ;; ...

  (->> children
       (map get-attrs)
       log)
  ;; => ({:class "cljcastr"}
  ;;     {:controls "true",
  ;;      :src "https://media.zencastr.com/audio-files/...",
  ;;      :class "cljcastr"})

  )

Now that we know how to get both the name of the tag and its attributes, let's combine them into a function that we can use to map over a list of elements and transform them into a nice Clojurey data structure.

Data is elemental

Climbing into our hammock ever so briefly, we sketch out the following:

{:tag "{{tag-name}}"
 :attrs {:k1 "{{v1}}"
         :k2 "{{v2}}"}}

Given this data structure and the functions we've already written, it's not too difficult to write the code:

(comment

  ;; ...
  
  (->> children
       (map (fn [el]
              {:tag (get-name el)
               :attrs (get-attrs el)}))
       log)
  ;; => ({:tag "h1", :attrs {:class "cljcastr"}}
  ;;     {:tag "audio",
  ;;      :attrs {:controls "true",
  ;;              :src "https://media.zencastr.com/audio-files/...",
  ;;              :class "cljcastr"}})

  )

The only thing missing from this is the inner text for the h1 element. Let's add a :text key to our map for elements that have text:

(comment

  ;; ...
  
  (->> children
       (map (fn [el]
              (let [text (get-text el)]
                (merge {:tag (get-name el)
                        :attrs (get-attrs el)}
                       (when-not (empty? text)
                         {:text text})))))
       log)
  ;; => ({:tag "h1", :attrs {:class "cljcastr"}, :text "cljcastr player"}
  ;;     {:tag "audio",
  ;;      :attrs {:controls "true",
  ;;              :src "https://media.zencastr.com/audio-files/...",
  ;;              :class "cljcastr"}})

  )

Let's turn this into a nice function, and whilst we're at it, add all of the keys that create-el takes. We also want to handle the case where the function is called with a Clojure representation of an element (i.e. a map), in which case it should just return the map:

(ns cljcastr.player)

;; ...
;; (defn add-style! ...)

(defn el->clj
  "Given an HTML element, returns a map containing :tag, :id, :classes, :styles,
   :text, and :attrs."
  [el]
  (if (map? el)
    el
    (let [el (get-el el)]
      (->> {:tag (get-name el)
            :id (get-id el)
            :classes (get-classes el)
            :styles (get-styles el)
            :text (get-text el)
            :attrs (get-attrs el)}
           (remove (fn [[_ v]] (empty? v)))
           (into {})))))

;; (defn create-el ...)
;; ...

(comment

  ;; ...
  
  (->> children
       (map el->clj)
       log)
  ;; => ({:tag "h1",
  ;;      :classes ("cljcastr"),
  ;;      :text "cljcastr player",
  ;;      :attrs {:class "cljcastr"}}
  ;;     {:tag "audio",
  ;;      :classes ("cljcastr"),
  ;;      :attrs {:controls "true",
  ;;              :src "https://media.zencastr.com/audio-files/...",
  ;;              :class "cljcastr"}})

  )

I wonder if we can roundtrip an element to a map back to an element?

(comment

  ;; ...

  (->> (create-el {:tag "button"
                   :id "button-play"
                   :classes ["cljcastr" "playback-button"]
                   :styles {:padding "0px"
                            :margin "0px"
                            :border "0px"
                            :background "transparent"
                            :cursor "pointer"}
                   :attrs {:aria-label "Play"}})
       el->clj
       create-el
       log)
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

Looking good! From this, it's pretty straightforward to turn this into an HTML string:

(comment

  ;; ...
  
  (->> children
       (map el->clj)
       (map (fn [{:keys [tag attrs text]}]
              (let [attrs-str (if (empty? attrs)
                                ""
                                (str " "
                                     (->> attrs
                                          (map (fn [[k v]] (str (name k) "=\"" v "\"")))
                                          (str/join " "))))]
                (str "<" tag attrs-str ">" text "</" tag ">"))))
       (str/join "\n")
       log)
  ;; => "<h1 class=\"cljcastr\">cljcastr player</h1>\n
  ;;     <audio controls=\"true\"
  ;;       src=\"https://media.zencastr.com/audio-files/...\"
  ;;       class=\"cljcastr\"></audio>"

  )

That looks pretty reasonable, so let's extract a few functions:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn el->clj ...)

(defn attrs->str
  "Given a map of attributes, returns a string of k=\"v\"..."
  [attrs]
  (->> attrs
       (map (fn [[k v]] (str (name k) "=\"" v "\"")))
       (str/join " ")))

(defn el->html
  "Given an element (or the Clojure representation of an element), returns an
   HTML string."
  [el]
  (let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
        attrs-str (attrs->str attrs)]
    (str "<" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">"
         text
         "</" tag ">")))

;; (defn create-el ...)
;; ...

(comment

  ;; ...
  
  (->> children
       (map el->html)
       (str/join "\n")
       log)
  ;; => "<h1 class=\"cljcastr\">cljcastr player</h1>\n
  ;;     <audio controls=\"true\"
  ;;       src=\"https://media.zencastr.com/audio-files/...\"
  ;;       class=\"cljcastr\"></audio>"

  )

Just as we're about to paste our HTML into index.html, a thought occurs to us. We wrote some mucky DOM-tacular JS-interop code to create elements, but now we have an elegant Clojure data structure to describe our elements, and we can turn that into HTML, so why not into DOM nodes as well?

Well, given the way we've built up our functions for dealing with the DOM, it turns out we can do this easily!

(comment

  ;; ...

  (-> (create-el {:tag "h1", :attrs {:class "cljcastr"}, :text "cljcastr player"})
      log)
  ;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]
  
  )

Looking at the browser console, we're convinced we're onto something:

  <h1 class="cljcastr">cljcastr player</h1>                          nrepl.cljs:10

Looks great! Now that we have a nicer way to build up elements interactively, as well as a way to dehydrate elements into HTML, let's think about what it would take to dehydrate the entire DOM tree rooted at our body tag.

We pretty much have everything we need:

(comment

  ;; ...

  (->> (get-els "body > .cljcastr")
       (map el->html)
       (str/join "\n")
       log)
  ;; => "<h1 class=\"cljcastr\">cljcastr player</h1>\n
  ;;     <audio controls=\"true\"
  ;;       src=\"https://media.zencastr.com/audio-files/...\"
  ;;       class=\"cljcastr\"></audio>"

  )

This looks great, so let's build the full podcast player we had before back up in our REPL and try dehydrating that into HTML. Might as well reload the page to make sure we're starting out with a pristine REPL. Remember to eval the buffer (C-c C-k) after reloading!

The resurrection

To rebuild our player, we could just step through the entire Rich comment from the beginning, evaluating each form one by one, but this sounds like a lot of work! Let's add a new add-attrs! function and another adornment to our create-el function to make life easier on ourselves:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn set-attrs! ...)
;; ...

(defn add-attrs! [el attrs]
  (let [el (get-el el)]
    (set-attrs! el (merge (get-attrs el) attrs))))

;; (defn get-id ...)
;; ...
;; (defn el->html ...)

(defn create-el [{:keys [tag id classes styles text attrs children] :as args}]
  (-> (js/document.createElement tag)
      (set-attrs! attrs)
      (set-id! id)
      (set-classes! classes)
      (set-styles! styles)
      (set-children! children)
      (set-text! text)))

;; (defn add-event-listener! ...)
;; ...

(comment

  ;; ...

  (->> (create-el {:tag "div"
                   :id "episode-container"
                   :classes ["cljcastr"]
                   :styles {:width "100%"
                            :display "flex"
                            :flex-direction "column"
                            :align-items "center"}
                   :children
                   [
                    ;; episode-info
                    (create-el {:tag "div"
                                :id "episode-info"
                                :classes ["cljcastr"]
                                :styles {:max-width "420px"
                                         :text-align "center"}
                                :children
                                [(create-el {:tag "h3"
                                             :id "podcast-title"
                                             :classes ["cljcastr"]
                                             :text "defn"})
                                 (create-el {:tag "h2"
                                             :id "episode-title"
                                             :classes ["cljcastr"]
                                             :text "876b4c306172 Cora Sutton"})
                                 (create-el {:tag "span"
                                             :id "episode-date"
                                             :classes ["cljcastr"]
                                             :styles {:color "grey"}
                                             :text "July 9, 2025"})]})

                    ;;episode-art
                    (create-el {:tag "div"
                                :id "episode-art-container"
                                :classes ["cljcastr"]
                                :children
                                [(create-el {:tag "img"
                                             :id "episode-art"
                                             :classes ["cljcastr"]
                                             :styles {:width "400px"
                                                      :border-radius "5%"
                                                      :padding "10px"}
                                             :attrs {:src "https://media.zencastr.com/image-files/5d9048546bacd9001de88f1c/a5b922ac-7294-4d58-9a33-4308f5d4f50b.png"}})]})

                    ;;audio
                    (-> (create-el {:tag "audio"
                                    :classes ["cljcastr"]
                                    :attrs {:controls true
                                            :src "https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3"}
                                    :styles {:display "none"}})
                        (add-event-listener! "timeupdate"
                                             (fn [ev]
                                               (let [audio (.-target ev)]
                                                 (update-playback-position-indicators!
                                                  (.-currentTime audio) (.-duration audio)
                                                  {:elapsed-time "#elapsed-time"
                                                   :remaining-time "#remaining-time"
                                                   :progress-slider "#progress-slider"
                                                   :progress-bar "#progress-bar"})))))

                    ;;progress
                    (create-el {:tag "div"
                                :id "progress"
                                :classes ["cljcastr"]
                                :styles {:width "390px"
                                         :margin "1.5em auto 0 auto"}
                                :children
                                [(create-el {:tag "span"
                                             :text (secs->ts 0)
                                             :classes ["cljcastr"]
                                             :id "elapsed-time"
                                             :styles {:margin "0 5px 0 5px"
                                                      :font-size "0.7rem"
                                                      :color "grey"
                                                      :float "left"}})
                                 (create-el {:tag "span"
                                             :text (secs->ts 0)
                                             :classes ["cljcastr"]
                                             :id "remaining-time"
                                             :styles {:margin "0 5px 0 5px"
                                                      :font-size "0.7rem"
                                                      :color "grey"
                                                      :float "right"}})
                                 (create-el {:tag "div"
                                             :id "progress-container"
                                             :classes ["cljcastr"]
                                             :styles {:position "relative"
                                                      :height "28px"
                                                      :clear "both"}
                                             :children
                                             [(create-el {:tag "progress"
                                                          :id "progress-bar"
                                                          :classes ["cljcastr"]
                                                          :attrs {:min 0
                                                                  :max 0
                                                                  :value 0}
                                                          :styles {:appearance "none"
                                                                   :height "8px"
                                                                   :width "100%"
                                                                   :border "0"
                                                                   :border-radius "4px"
                                                                   :margin "0"
                                                                   :overflow "hidden"
                                                                   :background-color "lightgray"
                                                                   :color "#f7931e"
                                                                   :position "absolute"
                                                                   :top "10px"
                                                                   :left "0px"
                                                                   :z-index 0}})
                                              (create-el {:tag "input"
                                                          :id "progress-slider"
                                                          :classes ["cljcastr"]
                                                          :attrs {:type "range"
                                                                  :min 0
                                                                  :max 0
                                                                  :value 0
                                                                  :step "any"
                                                                  :aria-label "Seek"
                                                                  :aria-valuetext (str (secs->ts 0)
                                                                                       " of "
                                                                                       (secs->ts 0))
                                                                  :aria-valuemax 0}
                                                          :styles {:appearance "none"
                                                                   :cursor "pointer"
                                                                   :height "28px"
                                                                   :width "100%"
                                                                   :border "0"
                                                                   :margin "0"
                                                                   :touch-action "manipulation"
                                                                   :position "absolute"
                                                                   :top "0px"
                                                                   :left "0px"
                                                                   :z-index 1
                                                                   :background-color "transparent"}})]})]})

                    ;;playback-controls
                    (create-el {:tag "div"
                                :id "playback-controls"
                                :classes ["cljcastr"]
                                :children
                                (->> [:backward :play :pause :forward :speed]
                                     (map (fn [button-name]
                                            [button-name (get playback-controls button-name)]))
                                     (map (fn [[button-name button]]
                                            (let [button-el (create-button button-name button
                                                                           {:seek-dur-sec seek-dur-sec
                                                                            :button-class "playback-button"
                                                                            :img-class "playback-icon"})]
                                              (-> (get-children button-el)
                                                  first
                                                  (set-styles! {:width "35px"
                                                                :filter (str "invert(59%) sepia(92%) saturate(611%) "
                                                                             "hue-rotate(343deg) brightness(97%) contrast(99%)")}))
                                              button-el))) log)})]})
       log
       list
       (set-children! "body"))
  ;; => #object[HTMLBodyElement [object HTMLBodyElement]]

  (let [audio (get-el "audio.cljcastr")]
    (set-text! "#elapsed-time" (secs->ts (.-currentTime audio)))
    (set-text! "#remaining-time" (secs->ts (- (.-duration audio)
                                              (.-currentTime audio))))
    (add-attrs! "#progress-bar" {:max (.-duration audio)
                                 :value (.-currentTime audio)})
    (add-attrs! "#progress-slider" {:max (.-duration audio)
                                    :value (.-currentTime audio)
                                    :aria-valuetext (str (secs->ts (.-currentTime audio))
                                                         " of "
                                                         (secs->ts (.-duration audio)))
                                    :aria-valuemax (.-duration audio)}))
  ;; => #object[HTMLInputElement [object HTMLInputElement]]
  
  )

Much like Lazarus, our podcast player hath now been resurrected! And also like Lazarus, the method of resurrection required divine powers. But fear not! for this shall be the last time we rely on the divine to rebuild our player.

It's an unstable era

Let's get back to dehydration, shall we? We can use the same method as before, when we had a really simple player:

(comment

  ;; ...

  (->> (get-els "body > .cljcastr")
       (map el->html)
       (str/join "\n")
       log)
  ;; => "<div id=\"episode-container\" class=\"cljcastr\"
  ;;       style=\"width: 100%; display: flex; flex-direction: column;
  ;;       align-items: center;\">
  ;;       defn876b4c306172 Cora SuttonJuly 9, 202500:001:29:30</div>"

  )

Hrm, that's not very helpful. First of all, we're only getting the direct children of our body element, which in this case is a single child: the episode-container div. What we'd actually like is the entire tree of elements rooted at body.

Let's see if we can figure out how to do that.

(comment

  ;; ...

  (->> (get-els "body > .cljcastr")
       (map (fn [el]
              (let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
                    attrs-str (attrs->str attrs)
                    tag-open (str "  <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
                    tag-close (str "</" tag ">")
                    children (get-children el)]
                (if (seq children)
                  (str/join "\n"
                            (concat [tag-open]
                                    (map el->html children)
                                    [tag-close]))
                  (str tag-open text tag-close)))))
       (str/join "\n")
       log)
  ;; => "<div id=\"episode-container\" class=\"cljcastr\" style=\"...\">\n
  ;;       <div id=\"episode-info\" class=\"cljcastr\" style=\"...\">defn876b4c306172 Cora SuttonJuly 9, 2025</div>\n
  ;;       <div id=\"episode-art-container\" class=\"cljcastr\"></div>\n
  ;;       <audio controls=\"true\" src=\"https://media.zencastr.com/audio-files/...\" class=\"cljcastr\" style=\"display: none;\"></audio>\n
  ;;       <div id=\"progress\" class=\"cljcastr\" style=\"...\">00:37-1:28:53</div>\n
  ;;       <div id=\"playback-controls\" class=\"cljcastr\"></div>\n
  ;;       </div>"

  )

OK, this is progress, but we're only grabbing the first level of children. In order to include grandchildren and great-grandchildren and great-great-grandchildren (and so on to the last syllable of recorded time), we'll need to include our new code in el->html itself:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn attrs->str ...)

(defn el->html
  "Given an element (or the Clojure representation of an element), returns an
   HTML string."
  [el]
  (let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
        attrs-str (attrs->str attrs)
        tag-open (str "  <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
        tag-close (str "</" tag ">")
        children (get-children el)]
    (if (seq children)
      (str/join "\n"
                (concat [tag-open]
                        (map el->html children)
                        [tag-close]))
      (str tag-open text tag-close))))

;; (defn create-el ...)
;; ...

(comment

  ;; ...

  (->> (get-els "body > .cljcastr")
       (map el->html)
       (str/join "\n")
       log)
  ;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
  ;;                        '[object Text]' is not a valid selector.\",
  ;;             ...}"

  )

Well, that doesn't look promising. Let's see if we can figure this out.

Textual healing

Looking back out at the HTML we generated in previously, we get a hint about which element might be the culprit:

  ;; => "<div id=\"episode-container\" class=\"cljcastr\" style=\"...\">\n
  ;;       <div id=\"episode-info\" class=\"cljcastr\" style=\"...\">defn876b4c306172 Cora SuttonJuly 9, 2025</div>\n

Our #episode-info div looks a bit odd, doesn't it. Let's take a closer look:

(comment

  ;; ...

  (get-children "#episode-info")
  ;; => (#object[HTMLHeadingElement [object HTMLHeadingElement]]
  ;;     #object[HTMLHeadingElement [object HTMLHeadingElement]]
  ;;     #object[HTMLSpanElement [object HTMLSpanElement]])

  )

OK, so #episode-info has three children: two headings and a span. Let's feed the first child to el->html and see what happens:

(comment

  ;; ...

  (-> (get-children "#episode-info")
      first
      el->html)
  ;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
  ;;                        '[object Text]' is not a valid selector.\",
  ;;             ...}"

  (-> (get-children "#episode-info")
      first
      get-children
      log)
  ;; => (#object[Text [object Text]])

  )

Ah yes, there it is. So there seems to be something special about a Text node that causes el->html to blow up. Let's see what we can actually do with this type of object. Reading the documentation, we see that it inherits from the CharacterData class, which has a handy data attribute that is "string representing the textual data contained in this object". Let's try that out:

(comment

  ;; ...

  (-> (get-children "#episode-info")
      first
      get-children
      first
      log)
  ;; => #object[Text [object Text]]

  (-> (get-children "#episode-info")
      first
      get-children
      first
      (.-data))
  ;; => "defn"

  )

Armed with this knowledge, we should be able to fix get-text to handle Text nodes:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-name ...)

(defn get-text
  "Returns the inner text of an element."
  [el]
  (let [el (get-el el)]
    (if (instance? js/CharacterData el)
      (.-data el)
      (or (.-textContent el)
          (.-innerText el)))))

;; (defn set-text! ...)
;; ...

(comment

  ;; ...

  (-> (get-children "#episode-info")
      first
      get-children
      first
      get-text)
  ;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
  ;;                        '[object Text]' is not a valid selector.\",
  ;;             ...}"

  )

Hrm, we're back to this. Let's trace through get-text line by line to see if we can figure out what's going on:

(let [el (get-el el)]
  ...)

So we'd be calling (get-el) with our Text node. Let's try that and see what happens:

(comment

  ;; ...

  (-> (get-children "#episode-info")
      first
      get-children
      first
      get-el)
  ;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
  ;;                        '[object Text]' is not a valid selector.\",
  ;;             ...}"

  )

Aha, so the problem is in get-el, which looks like this:

(defn get-el [selector]
  (if (instance? js/Element selector)
    selector
    (js/document.querySelector selector)))

What we're trying to do here is just return the selector arg if we're passed an element, otherwise assume selector is a string and use Document.querySelector to look up the element. But wait, why should we assume it's a string? Why not actually check? In fact, why not actually write the function in the same way we would describe it in English?

If selector is a string, returns the element matching CSS selector. Otherwise, assume it's already an element and just return it.

And whilst we're at it, why not update the docstring of the function to say exactly this?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn secs->ts ...)

(defn get-el
  "If `selector` is a string, returns the element matching CSS selector.
   Otherwise, assume it's already an element and just return it."
  [selector]
  (if (string? selector)
    (js/document.querySelector selector)
    selector))

;; (defn get-els ...)
;; ...

(comment

  ;; ...

  (-> (get-children "#episode-info")
      first
      get-children
      first
      get-text)
  ;; => "defn"

  )

Much better!

However, we still have that annoying word "assume" in our docstring. 😬

Returns the element matching the specified CSS selector. If selector is already an element, just returns the element.

How about we stop assuming, and actually throw an exception when we get an argument we can't handle? Sounds good, but the reason we're in this mess is because we were treating anything that wasn't an Element object as a string, and we discovered that Text isn't a subclass of Element. So what we actually want is a common ancestor of both Text and Element, right? And luckily, there is just such a subclass: Node! 🎉

Let's use that:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn secs->ts ...)

(defn get-el
  "If `selector` is a string, returns the element matching CSS selector.
   If `selector` is a `Node`, returns it. Otherwise, throws an exception."
  [selector]
  (cond
    (string? selector)
    (js/document.querySelector selector)

    (instance? js/Node selector)
    selector

    :else
    (let [cls (-> selector .-constructor .-name)]
      (throw (ex-info (str "Expecting a string or a Node; got: " cls)
                      {:dom/error :dom/error.invalid-class
                       :js/class cls})))))

;; (defn get-els ...)
;; ...

(comment

  ;; ...

  (get-el "#podcast-title")
  ;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]

  (get-el (js/document.querySelector "#podcast-title"))
  ;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]

  (get-el 42)
  ;; => "#error {:message \"Expecting a string or a Node; got: Number\", ...}"

  (try
    (get-el 42)
    (catch js/Error e
      (ex-data e)))
  ;; => {:dom/error :dom/error.invalid-class, :js/class "Number"}

  (-> (get-children "#episode-info")
      first
      get-children
      first
      get-text)
  ;; => "defn"

  )

Let's try turning the #episode-info div into HTML:

(comment

  ;; ...

  (el->html "#episode-info")
  ;; => "#error {:message \"Could not find instance method: getAttribute\", ...}"

  )

George Clooney sings "I Am a Man of Constant Sorrow"

Let's slow down for a second and actually think this through. What we're dealing with is HTML like this:

<h3>defn</h3>

which is represented by a DOM node that we can create like this:

(create-el {:tag "h3", :text "defn"})

And if we currently try to turn this into HTML, we get the "Could not find instance method: getAttribute" error, which makes sense, because neither Text nor any of its parent classes (CharacterData and Node) have a .getAttribute() method, and el->html calls el->clj, which in turn will try to get all sorts of attributes from the poor Text object. 😭

We should be able to solve this by making sure el->html can handle a Text node:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn attrs->str ...)

(defn el->html
  "Given an element (or the Clojure representation of an element), returns an
   HTML string."
  [el]
  (if (instance? js/CharacterData el)
    (get-text el)
    (let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
          attrs-str (attrs->str attrs)
          tag-open (str "  <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
          tag-close (str "</" tag ">")
          children (get-children el)]
      (if (seq children)
        (str/join "\n"
                  (concat [tag-open]
                          (map el->html children)
                          [tag-close]))
        (str tag-open text tag-close)))))

;; (defn format-css ...)
;; ...

(comment

  ;; ...

  (el->html (js/Text. "foo"))
  ;; => "foo"

  (-> (create-el {:tag "h3", :text "defn"})
      el->html)
  ;; => "  <h3>\ndefn\n</h3>"

  )

That seems somewhat reasonable, so let's see what happens when we turn the entire #episode-info div into HTML now:

(comment

  ;; ...

  (el->html "#episode-info")
  ;; => "  <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
  ;;       <h3 id=\"podcast-title\" class=\"cljcastr\">\ndefn\n</h3>\n
  ;;       <h2 id=\"episode-title\" class=\"cljcastr\">\n876b4c306172 Cora Sutton\n</h2>\n
  ;;       <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\nJuly 9, 2025\n</span>\n
  ;;       </div>"

  )

Hey, not bad!

HTML at last

Feeling confident, we go ahead and dump the entire DOM to HTML:

(comment

  ;; ...

  (-> (el->html "html")
      log)
  ;; => "  <html xmlns=\"http://www.w3.org/1999/xhtml\">\n
  ;;       <head>\n\n
  ;;       \n
  ;;       <title>\ncljcastr episode player\n</title>\n\n
  ;;       \n
  ;;       <link rel=\"stylesheet\" href=\"main.css\"></link>\n\n\n
  ;;       \n
  ;;       <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n\n
  ;;       \n
  ;;       <script>\nvar SCITTLE_NREPL_WEBSOCKET_PORT = 1340;\n</script>\n\n
  ;;       \n
  ;;       <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n\n\n\n
  ;;       <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n\n\n</head>\n\n\n\n
  ;;       <body>\n
  ;;       <div class=\"translate-tooltip-mtz blue sm-root translate hidden_translate\">
  ;;       <div class=\"header-wrapper\">
  ;;       <div class=\"header-controls\">
  ;;       ...
  ;;       </div>
  ;;       <span class=\"translate-button-mtz hidden_translate blue\"></span>\n
  ;;       <audio class=\"audio-for-speech\" src=\"\"></audio>\n
  ;;       <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
  ;;       <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
  ;;       <h3 id=\"podcast-title\" class=\"cljcastr\">\ndefn\n</h3>\n
  ;;       <h2 id=\"episode-title\" class=\"cljcastr\">\n876b4c306172 Cora Sutton\n</h2>\n
  ;;       <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\nJuly 9, 2025\n</span>\n
  ;;       </div>\n
  ;;       ...
  ;;       <button aria-label=\"Playback speed\" id=\"button-speed\" class=\"cljcastr playback-button\" style=\"padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;\">\n
  ;;       <img src=\"https://www.svgrepo.com/show/151785/speed-meter.svg\" draggable=\"false\" id=\"button-speed-icon\" class=\"cljcastr playback-icon\" style=\"width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);\"></img>\n
  ;;       </button>\n
  ;;       </div>\n
  ;;       </div>\n
  ;;       </body>\n
  ;;       </html>"

Now that's more like it! Better yet, if we look in the browser console, we see some HTML:

HTML code

Let's copy this HTML from the console and paste it into our index.html:

  <html xmlns="http://www.w3.org/1999/xhtml">
  <head>

  
  <title>
cljcastr episode player
</title>

  
  <link rel="stylesheet" href="main.css"></link>


  
  <script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js" type="application/javascript"></script>

  
  <script>
var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;
</script>

  
  <script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js" type="application/javascript"></script>



  <script type="application/x-scittle" src="player.cljs"></script>


</head>



  <body>
  <div id="episode-container" class="cljcastr" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
  <div id="episode-info" class="cljcastr" style="max-width: 420px; text-align: center;">
  <h3 id="podcast-title" class="cljcastr">
defn
</h3>
  <h2 id="episode-title" class="cljcastr">
876b4c306172 Cora Sutton
</h2>
  <span id="episode-date" class="cljcastr" style="color: grey;">
July 9, 2025
</span>
</div>
  <div id="episode-art-container" class="cljcastr">
  <img src="https://media.zencastr.com/image-files/5d9048546bacd9001de88f1c/a5b922ac-7294-4d58-9a33-4308f5d4f50b.png" id="episode-art" class="cljcastr" style="width: 400px; border-radius: 5%; padding: 10px;"></img>
</div>
  <audio controls="true" src="https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3" class="cljcastr" style="display: none;"></audio>
  <div id="progress" class="cljcastr" style="width: 390px; margin: 1.5em auto 0px;">
  <span id="elapsed-time" class="cljcastr" style="margin: 0px 5px; font-size: 0.7rem; color: grey; float: left;">
00:00
</span>
  <span id="remaining-time" class="cljcastr" style="margin: 0px 5px; font-size: 0.7rem; color: grey; float: right;">
1:29:30
</span>
  <div id="progress-container" class="cljcastr" style="position: relative; height: 28px; clear: both;">
  <progress min="0" max="5370.949958" value="0" id="progress-bar" class="cljcastr" style="color: rgb(247, 147, 30); top: 10px; overflow: hidden; background-color: lightgray; width: 100%; z-index: 0; appearance: none; position: absolute; border: 0px; border-radius: 4px; height: 8px; left: 0px; margin: 0px;"></progress>
  <input min="0" value="0" aria-valuemax="5370.949958" type="range" style="top: 0px; background-color: transparent; width: 100%; cursor: pointer; z-index: 1; appearance: none; position: absolute; border: 0px; touch-action: manipulation; height: 28px; left: 0px; margin: 0px;" aria-valuetext="00:00 of 1:29:30" max="5370.949958" id="progress-slider" class="cljcastr" aria-label="Seek" step="any"></input>
</div>
</div>
  <div id="playback-controls" class="cljcastr">
  <button aria-label="Back 15 seconds" id="button-backward" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
  <img src="https://www.svgrepo.com/show/495036/backward-15-seconds.svg" draggable="false" id="button-backward-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
  <button aria-label="Play" id="button-play" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
  <img src="https://www.svgrepo.com/show/436852/play-circle-fill.svg" draggable="false" id="button-play-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
  <button aria-label="Pause" id="button-pause" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
  <img src="https://www.svgrepo.com/show/436829/pause-circle-fill.svg" draggable="false" id="button-pause-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
  <button aria-label="Forward 15 seconds" id="button-forward" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
  <img src="https://www.svgrepo.com/show/495321/forward-15-seconds.svg" draggable="false" id="button-forward-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
  <button aria-label="Playback speed" id="button-speed" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
  <img src="https://www.svgrepo.com/show/151785/speed-meter.svg" draggable="false" id="button-speed-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
</div>
</div>
<protonpass-root-c873 data-protonpass-role="root" data-protonpass-theme="os"></protonpass-root-c873>
</body>

</html>

Saving the file, we take a deep breath and refresh the page in the browser. Et voilà, our page reappears just as we'd left it! 🎉

This is wonderful and all, but that HTML that we produced is a bit of an eyesore:

  <html xmlns="http://www.w3.org/1999/xhtml">
  <head>

  
  <title>
cljcastr episode player
</title>

  
  <link rel="stylesheet" href="main.css"></link>

  ...

</head>



  <body>
  <audio class="audio-for-speech" src=""></audio>
  <div class=\"translate-tooltip-mtz blue sm-root translate hidden_translate\">
  <div class=\"header-wrapper\">
  <div class=\"header-controls\">
  <span class=\"sound-translate\"></span>
  <span class=\"copy blue\"></span>
  <span class=\"settings-translator\"></span>
  </div>
  <div class=\"header-title\">translator</div>
  <div class=\"translate-icons\">
  <img class=\"from\" src=\"\"></img>
  <img class=\"translate-arrow\" src=\"chrome-extension://bebmphofpgkhclocdbgomhnjcpelbenh/images/right-arrow.png\"></img>
  <div class=\"translate_to_dropdown\">
  <button class=\"dropbtn dropdown-toggle\" type=\"button\" id=\"dropdownMenuButton1\" aria-expanded=\"false\"></button>
  <ul class=\"dropdown-content-wrapper languageSelector blue\">
  <li>
  <a class=\"dropdown-item\" data-lang=\"af\">Afrikaans</a>
  </li>
  <li>
  <a class=\"dropdown-item\" data-lang=\"sq\">Albanian - shqipe</a>
  </li>
  <li>
  <a class=\"dropdown-item\" data-lang=\"ar\">Arabic - ‎‫العربية‬‎</a>
  </li>
  ...
  <li>
  <a class=\"dropdown-item\" data-lang=\"yi\">Yiddish - יידיש</a>
  </li>
  </ul>
  </div>
  </div>
  </div>
  <div class=\"translated-text\">
  ...
  <div id="episode-container" class="cljcastr" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
  <div id="episode-info" class="cljcastr" style="max-width: 420px; text-align: center;">
  ...
</button>
</div>
</div>
<protonpass-root-c873 data-protonpass-role="root" data-protonpass-theme="os"></protonpass-root-c873>
</body>

</html>

In addition to all this whitespace, what's that weird stuff we didn't add to the DOM?

  <audio class="audio-for-speech" src=""></audio>
  <div class=\"translate-tooltip-mtz blue sm-root translate hidden_translate\">
  <div class=\"header-wrapper\">
  <div class=\"header-controls\">
  ...
<protonpass-root-c873 data-protonpass-role="root" data-protonpass-theme="os"></protonpass-root-c873>

It seems that extensions occasionally shove stuff into the DOM, so maybe we should give ourselves a way to filter out nodes we don't want. Let's start by adding a new arity to get-children that lets us filter and remove children using an options map:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn set-classes! ...)

(defn get-children
  "Returns the children of the specified element. Accepts an `opts` map with the
   following keys:

     `:filter-children` - predicate passed to `filter` on list of children
     `:remove-children` - predicate passed to `remove` on list of children

   `filter` is called before `remove`."
  ([el]
   (get-children el {}))
  ([el {:keys [filter-children remove-children]
        :or {filter-children (constantly true)
             remove-children (constantly false)} :as opts}]
   (->> (get-el el)
        .-childNodes
        seq
        (filter filter-children)
        (remove remove-children))))

;; (defn add-child! ...)
;; ...

(comment

  ;; ...

  (->> (get-children "#episode-info")
       log)
  ;; => (#object[Text [object Text]]
  ;;     #object[HTMLHeadingElement [object HTMLHeadingElement]]
  ;;     #object[Text [object Text]]
  ;;     #object[HTMLHeadingElement [object HTMLHeadingElement]]
  ;;     #object[Text [object Text]]
  ;;     #object[HTMLSpanElement [object HTMLSpanElement]]
  ;;     #object[Text [object Text]])

  (->> (get-children "#episode-info"
                     {:filter-children (partial instance? js/HTMLHeadingElement)})
       log
       (map get-text))
  ;; => ("\ndefn\n" "\n876b4c306172 Cora Sutton\n")

  (->> (get-children "#episode-info"
                     {:remove-children (partial instance? js/Text)})
       log
       (map get-text))
  ;; => ("\ndefn\n" "\n876b4c306172 Cora Sutton\n" "\nJuly 9, 2025\n")

  )

Now we need a way to pass those options in to get-children from el->html:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn attrs->str ...)

(defn el->html
  "Given an element (or the Clojure representation of an element), returns an
   HTML string."
  ([el]
   (el->html el {}))
  ([el opts]
   (if (instance? js/CharacterData el)
     (get-text el)
     (let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
           attrs-str (attrs->str attrs)
           tag-open (str "  <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
           tag-close (str "</" tag ">")
           children (get-children el opts)]
       (if (seq children)
         (str/join "\n"
                   (concat [tag-open]
                           (map #(el->html % opts) children)
                           [tag-close]))
         (str tag-open text tag-close))))))

;; (defn format-css ...)
;; ...

(comment

  ;; ...

  (el->html "#episode-info")
  ;; => "  <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n\n
  ;;       \n
  ;;       <h3 id=\"podcast-title\" class=\"cljcastr\">\n\ndefn\n\n</h3>\n\n
  ;;       \n
  ;;       <h2 id=\"episode-title\" class=\"cljcastr\">\n\n876b4c306172 Cora Sutton\n\n</h2>\n\n
  ;;       \n
  ;;       <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\n\n
  ;;       July 9, 2025\n\n</span>\n\n\n
  ;;       </div>"

  ;; Remove empty text nodes
  (el->html "#episode-info"
            {:remove-children #(empty? (str/replace (get-text %) #"[\n\s]+" ""))})
  ;; => "  <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
  ;;       <h3 id=\"podcast-title\" class=\"cljcastr\">\n\ndefn\n\n</h3>\n
  ;;       <h2 id=\"episode-title\" class=\"cljcastr\">\n\n876b4c306172 Cora Sutton\n\n</h2>\n
  ;;       <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\n\nJuly 9, 2025\n\n</span>\n
  ;;       </div>"

  )

Now we have what we need to clean up our HTML a little bit:

(comment

  ;; ...

  (let [empty-text? #(and (instance? js/Text %)
                          (empty? (str/replace (get-text %) #"[\n\s]+" "")))]
    (el->html "html"
              {:remove-children (fn [el]
                                  (or (empty-text? el)
                                      ;; Bye-bye Proton Pass!
                                      (str/starts-with? (or (get-name el) "")
                                                        "protonpass-")
                                      ;; Be gone, Translator!
                                      (some #(str/starts-with? % "translate-")
                                            (get-classes el))
                                      (and (= "audio" (get-name el))
                                           (not (some #{"cljcastr"} (get-classes el))))))}))
  ;; => "  <html xmlns=\"http://www.w3.org/1999/xhtml\">\n
  ;;       <head>\n
  ;;       <title>\n\ncljcastr episode player\n\n</title>\n
  ;;       <link rel=\"stylesheet\" href=\"main.css\"></link>\n
  ;;       <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n
  ;;       <script>\n\n
  ;;       var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;\n\n
  ;;       </script>\n
  ;;       <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n
  ;;       <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n
  ;;       </head>\n
  ;;       <body>\n
  ;;       <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
  ;;       ...
  ;;       </div>\n
  ;;       </div>\n
  ;;       </body>\n
  ;;       </html>"

  )

This is cool and all, but the :remove-children function is getting a bit hairy, and we're haunted by the fact that we'll need to keep adding to it every time we install a new extension that inserts nodes into the DOM. This is the classic allowlist / denylist problem, and we can take a page out of the secure development notebook and deny all nodes by default, keeping only the ones we actually want.

Plausible deniability

We know that this means using :filter-children instead of :remove-children, but what should we allow? How about this:

  • The html, head, and body elements themselves
  • All elements that are descendants of the head element (i.e. children, grandchildren, great grandchildren, etc.)
  • All elements that are descendants of the body element that have the "cljcastr" class

Let's start by adding a function to check whether an element has a specific class:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-classes ...)

(defn has-class?
  "Returns true if the element's list of classes contains the specified class."
  [el cls]
  (-> (get-classes el)
      set
      (contains? cls)))

;; (defn set-classes! ...)
;; ...

(comment

  ;; ...

  (has-class? "#button-backward" "cljcastr")
  ;; => true

  )

Now, how about a function that checks whether one element is a descendant of another?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-els ...)

(defn descendant?
  "Returns truthy if the specified element is an ancestor of the element named by
   the specified CSS selector. NOTE: `ancestor-sel` must be a string containing
   a CSS selector, not an element!"
  [el ancestor-sel]
  (-> (get-el el)
      (.closest ancestor-sel)))

;; (defn get-attr ...)
;; ...

(comment

  ;; ...

  (descendant? "#button-backward" "#episode-container")
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  (descendant? "#button-backward" "#episode-info")
  ;; => nil

  )

Given this, we should be able to write the :filter-children function of our dreams:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn ??? ...)

;; (defn ??? ...)
;; ...

(comment

  ;; ...

  (el->html "html"
            {:filter-children (fn [el]
                                (cond
                                  (instance? js/Text el)
                                  (-> (get-text el)
                                      (str/replace #"[\n\s]+" "")
                                      not-empty)
                                  
                                  (instance? js/Element el)
                                  (or (contains? #{"html" "head" "body"}
                                                 (get-name el))
                                      (descendant? el "head")
                                      (and (descendant? el "body")
                                           (has-class? el "cljcastr")))))})
  ;; => "  <html xmlns=\"http://www.w3.org/1999/xhtml\">\n
  ;;       <head>\n
  ;;       <title>cljcastr episode player</title>\n
  ;;       <link rel=\"stylesheet\" href=\"main.css\"></link>\n
  ;;       <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n
  ;;       <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>\n
  ;;       <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n
  ;;       <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n
  ;;       </head>\n
  ;;       <body>\n
  ;;       <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
  ;;       <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
  ;;       ...
  ;;       </div>\n
  ;;       </div>\n
  ;;     </body>\n
  ;;     </html>\n
  ;;     "

  )

This is definite progress! We're still not loving that indentation, though.

Indenting for fun and profit

Let's think about how we'd like our HTML indented:

  1. Indent children by two spaces
  2. <head> and <body> should not be indented
  3. <head> and <body> should reset indentation
  4. The content of text nodes should go on the same line as the parent node

In other words, we'd like our HTML to look like this:

<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title>cljcastr episode player</title>
  ...
</head>
<body>
  <div id="episode-container" class="cljcastr" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
    <div id="episode-info" class="cljcastr" style="max-width: 420px; text-align: center;">
      <h3 id="podcast-title" class="cljcastr">defn</h3>
      <h2 id="episode-title" class="cljcastr">876b4c306172 Cora Sutton</h2>
      <span id="episode-date" class="cljcastr" style="color: grey;">July 9, 2025</span>
    </div>
    ...
</body>
</html>

Let's start with rule #4. First of all, what does the content of one of our text nodes look like?

(comment

  ;; ...

  (->> (get-children "#podcast-title")
       first
       get-text)
  ;; => "\ndefn\n"

  )

OK, it starts with a newline and ends with two newlines. How about we get rid of those?

(comment

  ;; ...

  (->> (get-children "#podcast-title")
       first
       get-text
       str/trim)
  ;; => "defn"

  )

Great! Now we can plug this into el->html. We also need to stop putting newlines between the children (this will mess up our formatting, but we'll address that as we work our way through the rest of the rules in our list).

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn attrs->str ...)

(defn el->html
  "Given an element (or the Clojure representation of an element), returns an
   HTML string."
  ([el]
   (el->html el {}))
  ([el opts]
   (if (instance? js/CharacterData el)
     (str/trim (get-text el))
     (let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
           attrs-str (attrs->str attrs)
           tag-open (str "  <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
           tag-close (str "</" tag ">")
           children (get-children el opts)]
       (if (seq children)
         (str/join ""
                   (concat [tag-open]
                           (map #(el->html % opts) children)
                           [tag-close]))
         (str tag-open text tag-close))))))

;; (defn format-css ...)
;; ...

(comment

  ;; ...

  (el->html "#podcast-title")
  ;; => "  <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>"

  )

Great! That's rule #4 ticked off the list. Of course, this has made a bit of a mess of other things:

(comment

  ;; ...

  (el->html "#episode-info")
  ;; => "  <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">  <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>  <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>  <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span></div>"

  )

Note the extreme lack of newlines there. We can address this by adding some indentation rules to our opts map:

  • :indent-level - number of levels to indent
  • :indent-str - string to use when indenting

Now for a little helper function:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn attrs->str ...)

(defn indent
  "Returns `indent-str` repeated `indent-level` times."
  [indent-level indent-str]
  (->> (repeat indent-level indent-str)
       (str/join "")))

;; (defn el->html ...)
;; ...

(comment

  ;; ...

  (->> (range 3)
       (map #(str (indent % "  ") "indent " %)))
  ;; => ("indent 0" "  indent 1" "    indent 2")

  )

Let's plug this into el->html:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn indent ...)

(defn el->html
  "Given an element (or the Clojure representation of an element), returns an
   HTML string."
  ([el]
   (el->html el {}))
  ([el {:keys [indent-level indent-str]
        :or {indent-level 0, indent-str "  "}
        :as opts}]
   (if (instance? js/CharacterData el)
     (str/trim (get-text el))
     (let [{:keys [tag attrs]} (if (map? el) el (el->clj el))
           attrs-str (attrs->str attrs)
           children-html (->> (get-children el opts)
                              (map #(el->html % (assoc opts :indent-level
                                                       (inc indent-level))))
                              (str/join ""))
           tag-open (str (indent indent-level indent-str)
                         "<" tag
                         (if (empty? attrs-str) "" (str " " attrs-str))
                         ">"
                         (if (str/ends-with? children-html "\n")
                           "\n"
                           ""))
           tag-close (str (if (str/ends-with? children-html "\n")
                            (indent indent-level indent-str)
                            "")
                          "</" tag ">\n")]
       (str tag-open children-html tag-close)))))

;; (defn format-css ...)
;; ...

(comment

  ;; ...

  (el->html "#episode-info")
  ;; => "<div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
  ;;       <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
  ;;       <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
  ;;       <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span>\n
  ;;     </div>\n"

  )

Biting our bottom lip to keep the fear at bay, we unleash el->html on the entire DOM:

(comment

  ;; ...

  (el->html "html"
            {:filter-children (fn [el]
                                (cond
                                  (instance? js/Text el)
                                  (-> (get-text el)
                                      (str/replace #"[\n\s]+" "")
                                      not-empty)
                                  
                                  (instance? js/Element el)
                                  (or (contains? #{"html" "head" "body"}
                                                 (get-name el))
                                      (descendant? el "head")
                                      (and (descendant? el "body")
                                           (has-class? el "cljcastr")))))})
  ;; => "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n
  ;;       <head>\n
  ;;         <title>cljcastr episode player</title>\n
  ;;         ...
  ;;       </head>\n
  ;;       <body>\n
  ;;         <audio class=\"audio-for-speech\" src=\"\"></audio>\n
  ;;         <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
  ;;           <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
  ;;             <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
  ;;             <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
  ;;             <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span>\n
  ;;           </div>\n
  ;;           ...
  ;;         </div>\n
  ;;       </body>\n
  ;;     </html>\n
  ;;     "

  )

This looks great! Or rather, almost great, because we forgot about rules #2 and #3:

2. `<head>` and `<body>` should not be indented
3. `<head>` and `<body>` should reset indentation

Turns out we can implement both of these rules with a one-line addition to el->html! Well, maybe two lines, because a comment seems appropriate.

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn indent ...)

(defn el->html
  "Given an element (or the Clojure representation of an element), returns an
   HTML string."
  ([el]
   (el->html el {}))
  ([el {:keys [indent-level indent-str]
        :or {indent-level 0, indent-str "  "}
        :as opts}]
   (if (instance? js/CharacterData el)
     (str/trim (get-text el))
     (let [{:keys [tag attrs]} (if (map? el) el (el->clj el))
           attrs-str (attrs->str attrs)
           ;; Reset indentation for <head> and <body> tags
           indent-level (if (#{"head" "body"} (str/lower-case tag)) 0 indent-level)
           children-html (->> (get-children el opts)
                              (map #(el->html % (assoc opts :indent-level
                                                       (inc indent-level))))
                              (str/join ""))
           tag-open (str (indent indent-level indent-str)
                         "<" tag
                         (if (empty? attrs-str) "" (str " " attrs-str))
                         ">"
                         (if (str/ends-with? children-html "\n")
                           "\n"
                           ""))
           tag-close (str (if (str/ends-with? children-html "\n")
                            (indent indent-level indent-str)
                            "")
                          "</" tag ">\n")]
       (str tag-open children-html tag-close)))))

;; (defn format-css ...)
;; ...

In case the two new lines where hard to spot, here they are again:

           ;; Reset indentation for <head> and <body> tags
           indent-level (if (#{"head" "body"} (str/lower-case tag)) 0 indent-level)

Now let's try again with the HTML dumping:

(comment

  ;; ...

  (el->html "html"
            {:filter-children (fn [el]
                                (cond
                                  (instance? js/Text el)
                                  (-> (get-text el)
                                      (str/replace #"[\n\s]+" "")
                                      not-empty)
                                  
                                  (instance? js/Element el)
                                  (or (contains? #{"html" "head" "body"}
                                                 (get-name el))
                                      (descendant? el "head")
                                      (and (descendant? el "body")
                                           (has-class? el "cljcastr")))))})
  ;; => "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n
  ;;     <head>\n
  ;;       <title>cljcastr episode player</title>\n
  ;;       <link rel=\"stylesheet\" href=\"main.css\"></link>\n
  ;;       <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n
  ;;       <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>\n
  ;;       <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n
  ;;       <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n
  ;;     </head>\n
  ;;     <body>\n
  ;;       <audio class=\"audio-for-speech\" src=\"\"></audio>\n
  ;;       <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
  ;;         <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
  ;;           <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
  ;;           <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
  ;;           <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span>\n
  ;;         </div>\n
  ;;         ...
  ;;       </div>\n
  ;;     </body>\n
  ;;     </html>\n
  ;;     "

  )

Looking at the browser console confirms that our HTML looks exactly as we wanted! And we could copy that sweet sweet HTML from the console and paste it into our index.html and move on with our lives... or we could shave just one more yak.

Shaving that final yak

It's a bit annoying that we have to keep remembering the elements we want to keep when dumping our HTML, so let's create a nice little set of functions to take care of that:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn update-playback-position-indicators! ...)

(defn keep-child? [el]
  (cond
    (instance? js/Text el)
    (-> (get-text el)
        (str/replace #"[\n\s]+" "")
        not-empty)
    
    (instance? js/Element el)
    (or (contains? #{"html" "head" "body"}
                   (get-name el))
        (descendant? el "head")
        (and (descendant? el "body")
             (has-class? el "cljcastr")))))

(defn dom->html []
  (el->html "html" {:filter-children keep-child?}))

;; (js/console.log ...)
;; ...

(comment

  ;; ...

  (do
    (log (dom->html))
    nil)
  ;; => nil

  )

Checking the console verifies that this is the HTML we're looking for.

But this is really not the yak we came here to shave. That big old annoying yak staring us in the face is the one that makes us use the mouse (the horror!) to copy a bunch of HTML from the browser console, and then pop back over to Emacs and switch to the index.html buffer and yank and then kill the old contents of the file and save. I mean, who has time for this kind of nonsense? Not us, that's for damn sure!

If only there was a way to save a file from the browser...

(comment

  ;; ...

  (let [filename "boring.html"
        html "<html><body><h1>Yawn...</body></html>"
        blob-url (-> (js/Blob. [html] (clj->js {:type "text/html"}))
                     js/URL.createObjectURL)]
    (-> (create-el {:tag "a"
                    :attrs {:download filename
                            :href blob-url}})
        .click))
  ;; => nil

  )

When we evaluate this form, we get a helpful save file dialog of the sort we've seen millions of times before, with boring.html helpfully prefilled.

Browser save file dialog

If you were to save this file in your public directory alongside index.html and then browse to http://localhost:1341/boring.html, you would be quite bored.

But that's OK, for we have learned the magic incantation for saving a file:

  1. Create a Blob containing some HTML and a text/html content type.
  2. Create a blob URL (i.e. a URL with blob: as its scheme) from said blob.
  3. Create an a element with its download attribute set to a filename of our choosing and its href attribute set to the blob URL we created.
  4. Click on the a element.

This is quite straightforward to turn into a couple of functions:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-event-listener! ...)

(defn save-file! [filename content content-type]
  (let [blob-url (-> (js/Blob. [content] (clj->js {:type content-type}))
                     js/URL.createObjectURL)]
    (-> (create-el {:tag "a"
                    :attrs {:download filename
                            :href blob-url}})
        .click)))

;; (defn create-button ...)
;; ...
;; (defn dom->html ...)

(defn save-html!
  ([]
   (save-html! "index.html"))
  ([filename]
   (save-html! filename (dom->html)))
  ([filename html]
   (save-file! filename html "text/html")))

;; (js/console.log ...)
;; ...
(comment

  ;; ...

  (save-html!)
  ;; => nil

  )

When the save file dialog pops up, let's save it over top of our existing index.html and then reload the browser.

The page looks exactly the same as before, but our index.html is a work of art, formatted as purdy as a peach, as we say back where ah'm from (locating my point of provence shall remain an exercise for the reader, though that information is readily available in the latest episode of defn—as of this writing, that is; I have no clue when you're reading this). 🍑

All is good in the world... except that clicking the buttons on our lovely player has no effect whatsoever. 😅

Button mashing

But fear not! 'Tis but the work of a moment to fix them. Moving back over to our player.cljs buffer, we mash C-c C-k (cider-load-buffer), then pop down to the bottom of our comment form and search backwards for "(def playback-controls". Once we find what we're looking for, we copy it and paste it into our namespace, right before our "Ready to play!" message:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn save-html! ...) 

(def playback-controls
  {:play {:src "https://www.svgrepo.com/show/436852/play-circle-fill.svg"
          :label "Play"
          :click-handler #(.play (get-el "audio.cljcastr"))}
   :pause {:src "https://www.svgrepo.com/show/436829/pause-circle-fill.svg"
           :label "Pause"
           :click-handler #(.pause (get-el "audio.cljcastr"))}
   :forward {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
             :label "Forward {{seek-dur-sec}} seconds"
             :click-handler #(skip-forward! (get-el "audio.cljcastr") seek-dur-sec)}
   :backward {:src "https://www.svgrepo.com/show/495036/backward-15-seconds.svg"
              :label "Back {{seek-dur-sec}} seconds"
              :click-handler #(skip-backward! (get-el "audio.cljcastr") seek-dur-sec)}
   :speed {:src "https://www.svgrepo.com/show/151785/speed-meter.svg"
           :label "Playback speed"}})

(js/console.log "Ready to play!")

(comment

  ;; ...

  )

We hit C-c C-k again, but are deeply saddened by an error message:

#error {:message "Unable to resolve symbol: seek-dur-sec",
        :data {:type :sci/error, :line 10, :column 30,
               :message "Unable to resolve symbol: seek-dur-sec",
               ;; ...
               },
        ;; ...
        }

Oops, looks like we need to grab seek-dur-sec as well:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn save-html! ...) 

(def seek-dur-sec 15)

;; (def playback-controls ...)

C-c C-k FTW?

Victory! 🏆

Or maybe more accurately, not defeat, since clicking the buttons still doesn't work. Which isn't that surprising, since we didn't actually attach any event listeners to anything. Let's see if we can figure out how to do that.

(comment

  ;; ...

  (doseq [[el-id {:keys [click-handler]}] playback-controls]
    (add-event-listener! (str "#button-" (name el-id)) "click"
                         click-handler))
  ;; => nil

  )

Now if we click the play button, the episode starts playing! We skip backwards and forwards in the episode, try pausing it, and finally convince ourselves that all the buttons work.

The only problem is that the elapsed time, remaining time, progress bar, and slider aren't working. 🤔

Oh yeah, we had a handler for that as well. And we might as well attach the listener to the slider that updates the playback position too:

(comment

  ;; ...

  (let [audio (get-el "audio.cljcastr")]
    (add-event-listener! "#progress-slider" "input"
                         #(->> % .-target .-value (set! (.-currentTime audio))))
    (add-event-listener! audio "timeupdate"
                         #(update-playback-position-indicators!
                           (.-currentTime audio) (.-duration audio)
                           (->> [:elapsed-time
                                 :remaining-time
                                 :progress-slider
                                 :progress-bar]
                                (map (fn [el-id]
                                       [el-id (str "#" (name el-id))]))
                                (into {}))))
    audio)
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  )

If we click play again, we see the timestamps updating and slider moving, and if we move the slider ourselves, we can successfully jump around in the episode. Life is good!

That is, until we hit C-c C-k again and realise that despite the console cheerily telling us that we're "Ready to play!", the buttons don't work. 😢

Of course, we can evaluate the two forms we just added to the Rich comment, but it's hardly a good user experience to expect our users to connect a ClojureScript REPL, paste in some code, and evaluate it before they can actually play an episode.

There is a pretty easy solution to this. If we just add the two magic forms right before our "Ready to play!" log statement, the listeners should be attached on page load. Let's try it:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (def playback-controls ...)

(doseq [[el-id {:keys [click-handler]}] playback-controls]
  (add-event-listener! (str "#button-" (name el-id)) "click"
                       click-handler))

(let [audio (get-el "audio.cljcastr")]
  (add-event-listener! "#progress-slider" "input"
                       #(->> % .-target .-value (set! (.-currentTime audio))))
  (add-event-listener! audio "timeupdate"
                       #(update-playback-position-indicators!
                         (.-currentTime audio) (.-duration audio)
                         (->> [:elapsed-time
                               :remaining-time
                               :progress-slider
                               :progress-bar]
                              (map (fn [el-id]
                                     [el-id (str "#" (name el-id))]))
                              (into {}))))
  audio)

;; (js/console.log ...)

If we save the file and reload the page, the buttons now work from the start. This is good, but there is one oddity that we'll notice in the console if we pause playback then evaluate the buffer with C-c C-k again:

  Added listener for click events to                                  nrepl.cljs:10
   ‣ <button class=​"cljcastr" id=​"button-speed"
             style=​"padding:​ 0px;​ margin:​ 0px;​ border:​ 0px;​ background:​ transparent;​ cursor:​ pointer;​"
             aria-label=​"Playback speed">​…​</button>​
  Added listener for input events to                                  nrepl.cljs:10
   ‣ <input class=​"cljcastr" id=​"progress-slider" type=​"range"
            min=​"0" max=​"3138.727422" value=​"0"step=​"any"
            style=​"top:​ 0px;​ background-color:​ transparent;​ width:​ 100%;​ cursor:​ pointer;​ z-index:​ 1;​ appearance:​ none;​ position:​ absolute;​ border:​ 0px;​ touch-action:​ manipulation;​ height:​ 28px;​ left:​ 0px;​ margin:​ 0px;​"
            aria-label=​"Seek" aria-valuetext=​"11:​58 of 52:​18" 
            aria-valuemax=​"3138.727422">​
  Added listener for timeupdate events to                             nrepl.cljs:10
   ‣ <audio class=​"cljcastr" controls style=​"display:​ none;​" 
            src=​"https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3">​</audio>​
  Ready to play!

And if we click play then pause again, we see that the click handlers are firing twice:

  Handling click on                                                   nrepl.cljs:10
   ‣ <button class=​"cljcastr" id=​"button-pause"
             style=​"padding:​ 0px;​ margin:​ 0px;​ border:​ 0px;​ background:​ transparent;​ cursor:​ pointer;​"
             aria-label=​"Pause">​…​</button>​
   ‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}
  Handling click on                                                   nrepl.cljs:10
   ‣ <button class=​"cljcastr" id=​"button-pause"
             style=​"padding:​ 0px;​ margin:​ 0px;​ border:​ 0px;​ background:​ transparent;​ cursor:​ pointer;​"
             aria-label=​"Pause">​…​</button>​
   ‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}

If we evaluate the buffer again, then click play and pause, we see the handlers firing thrice! 😱

  Handling click on                                                   nrepl.cljs:10
   ‣ <button class=​"cljcastr" id=​"button-pause"
             style=​"padding:​ 0px;​ margin:​ 0px;​ border:​ 0px;​ background:​ transparent;​ cursor:​ pointer;​"
             aria-label=​"Pause">​…​</button>​
   ‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}
  Handling click on                                                   nrepl.cljs:10
   ‣ <button class=​"cljcastr" id=​"button-pause"
             style=​"padding:​ 0px;​ margin:​ 0px;​ border:​ 0px;​ background:​ transparent;​ cursor:​ pointer;​"
             aria-label=​"Pause">​…​</button>​
   ‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}
  Handling click on                                                   nrepl.cljs:10
   ‣ <button class=​"cljcastr" id=​"button-pause"
             style=​"padding:​ 0px;​ margin:​ 0px;​ border:​ 0px;​ background:​ transparent;​ cursor:​ pointer;​"
             aria-label=​"Pause">​…​</button>​
   ‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}

OK, so it appears that every time we evaluate the buffer, we get a new click handler. And if we think about it for a moment, it makes sense, because these forms:

(doseq [[el-id {:keys [click-handler]}] playback-controls]
  (add-event-listener! (str "#button-" (name el-id)) "click"
                       click-handler))

(let [audio (get-el "audio.cljcastr")]
  (add-event-listener! "#progress-slider" "input"
                       #(->> % .-target .-value (set! (.-currentTime audio))))
  (add-event-listener! audio "timeupdate"
                       #(update-playback-position-indicators!
                         (.-currentTime audio) (.-duration audio)
                         (->> [:elapsed-time
                               :remaining-time
                               :progress-slider
                               :progress-bar]
                              (map (fn [el-id]
                                     [el-id (str "#" (name el-id))]))
                              (into {}))))
  audio)

;; (js/console.log ...)

are getting evaluated every time, since they are at the top level of our namespace. Luckily for us, if we whip out our microscope, we find a solution (and a precipitate, but that's beside the point): the mighty atom!

Atomic elbow

A common pattern in ClojureScript apps is to define a single atom that holds all of the state we care about. It is conventional to call this atom state, so let's follow said convention because we aren't iconoclastic hipsters, after all!

(ns cljcastr.player
  (:require [clojure.string :as str]))

(defonce state (atom {}))

;; (defn log ...)
;; ...

We use defonce instead of plain ol' def, because otherwise every time we evaluated the buffer, our state var would be rebound to a fresh new atom, thus defeating the point of keeping state in the first place.

Now that we've done this, let's move our code that attaches event listeners into a function:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (def playback-controls)

(defn init-ui! []
  (doseq [[el-id {:keys [click-handler]}] playback-controls]
    (add-event-listener! (str "#button-" (name el-id)) "click"
                         click-handler))
  (let [audio (get-el "audio.cljcastr")]
    (add-event-listener! "#progress-slider" "input"
                         #(->> % .-target .-value (set! (.-currentTime audio))))
    (add-event-listener! audio "timeupdate"
                         #(update-playback-position-indicators!
                           (.-currentTime audio) (.-duration audio)
                           (->> [:elapsed-time
                                 :remaining-time
                                 :progress-slider
                                 :progress-bar]
                                (map (fn [el-id]
                                       [el-id (str "#" (name el-id))]))
                                (into {}))))
    audio)
  (log "UI initialised"))

;; (js/console.log ...)

Now if we evaluate the buffer, we don't see "UI initialised!" get printed in the console. However, if we reload the page, we're back to our buttons not working, which makes sense, because we're never calling our new init-ui! function.

Let's fix that!

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn init-ui! ...) 

(when-not (:initialised? @state)
  (init-ui!)
  (swap! state assoc :initialised? true))

;; (js/console.log ...)

If we reload the page now, we'll see our handlers getting installed and our log statement telling us "UI initialised", as well as the usual chipper "Ready to play!". And if we evaluate the buffer a couple times, we see "Ready to play!" being logged, but nothing about handlers and initialisation.

Console with initialisation messages

Better yet, everything seems to work!

What has CSS ever done for us anyway?

Of course, we are not the sort of Clojurians to rest on our laurels, for there are still improvements to be made! It is considered a bit tacky these days to use inline styles, so let's see if we can take a page out of our el->html book and export our styles.

In fact, we already have a function to get a map of styles from an element (get-styles), and code in our set-styles! function that turns a map of styles into a CSS string, so it's but the work of a moment to squish them together:

(comment

  ;; ...
  
  (->> (get-styles "#progress-bar")
       (map (fn [[k v]]
              (str (name k) ": " v)))
       (str/join "; "))
  ;; => "color: rgb(247, 147, 30); top: 10px; overflow: hidden;
  ;;     background-color: lightgray; width: 100%; z-index: 0;
  ;;     appearance: none; position: absolute; border: 0px;
  ;;     border-radius: 4px; height: 8px; left: 0px; margin: 0px;"

  )

What would be really cool, though, would be to produce a snippet like this:

#progress-bar {
  color: rgb(247, 147, 30);
  top: 10px;
  overflow: hidden;
  background-color: lightgray;
  width: 100%;
  z-index: 0;
  appearance: none;
  position: absolute;
  border: 0px;
  border-radius: 4px;
  height: 8px;
  left: 0px;
  margin: 0px;
}

Honestly, that shouldn't be too tricky. Let's see what we can do:

(comment

  ;; ...

  (let [el (get-el "#progress-bar")
        styles (get-styles el)]
    (str "#" (get-id el) " {\n  "
         (->> styles
              (map (fn [[k v]]
                     (str (name k) ": " v)))
              (str/join ";\n  "))
         "\n}"))
  ;; => "#progress-bar {\n
  ;;       color: rgb(247, 147, 30);\n
  ;;       top: 10px;\n
  ;;       overflow: hidden;\n
  ;;       background-color: lightgray;\n
  ;;       width: 100%;\n
  ;;       z-index: 0;\n
  ;;       appearance: none;\n
  ;;       position: absolute;\n
  ;;       border: 0px;\n
  ;;       border-radius: 4px;\n
  ;;       height: 8px;\n
  ;;       left: 0px;\n
  ;;       margin: 0px;\n
  ;;     }"

  )

But what about elements without an ID?

(comment

  ;; ...

  (let [el (get-el "audio.cljcastr")
        styles (get-styles el)]
    (str "#" (get-id el) " {\n  "
         (->> styles
              (map (fn [[k v]]
                     (str (name k) ": " v)))
              (str/join ";\n  "))
         "\n}"))
  ;; => "# {\n  display: none;\n}"

  )

For that, let's fall back to the tag name, plus the class if it exists:

(comment

  ;; ...

  (let [el (get-el "audio.cljcastr")
        styles (get-styles el)
        id (get-id el)
        classes (get-classes el)
        sel (if (not-empty id)
              (str "#" (get-id el))
              (str (get-name el) (if (seq classes)
                                   (str "." (first classes)))))]
    (str sel " {\n  "
         (->> styles
              (map (fn [[k v]]
                     (str (name k) ": " v)))
              (str/join ";\n  "))
         "\n}"))
  ;; => "audio.cljcastr {\n  display: none;\n}"

  )

This is a bit clunky, though. It would be a lot cleaner if we used el->clj to turn the element into a Clojure datastructure:

(comment

  ;; ...

  (let [el (get-el "audio.cljcastr")
        styles (get-styles el)
        id (get-id el)
        classes (get-classes el)
        sel (if (not-empty id)
              (str "#" (get-id el))
              (str (get-name el) (if (seq classes)
                                   (str "." (first classes)))))]
    (str sel " {\n  "
         (->> styles
              (map (fn [[k v]]
                     (str (name k) ": " v)))
              (str/join ";\n  "))
         "\n}"))
  ;; => "audio.cljcastr {\n  display: none;\n}"

  )

This code is so special it deserves a name! And lo! that name shall be el->css, and it shall be good.

Ramses I from The Ten Commandments saying 'So let it be written, so let it be done'

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn el->html ...)

(defn format-css
  "Returns a CSS string with the specified selector and styles"
  [sel styles]
  (str sel " {\n  "
       (->> styles
            (map (fn [[k v]]
                   (str (name k) ": " v)))
            (str/join ";\n  "))
       "\n}"))

(defn el->css
  "Given an element (or the Clojure representation of an element), returns a CSS
   rule corresponding to the element's ID."
  [el]
  (let [el (log (if (map? el) el (-> (get-el el) el->clj)))
        {:keys [styles id tag classes]} el
        sel (if (not-empty id)
              (str "#" id)
              (str tag (if (seq classes)
                         (str "." (first classes)))))]
    (format-css sel styles)))

;; (defn create-el ...)
;; ...

(comment

  ;; ...

  (->> (el->css "#progress-bar")
       log)
  ;; => "#progress-bar {\n
  ;;       color: rgb(247, 147, 30);\n
  ;;       top: 10px;\n
  ;;       overflow: hidden;\n
  ;;       background-color: lightgray;\n
  ;;       width: 100%;\n
  ;;       z-index: 0;\n
  ;;       appearance: none;\n
  ;;       position: absolute;\n
  ;;       border: 0px;\n
  ;;       border-radius: 4px;\n
  ;;       height: 8px;\n
  ;;       left: 0px;\n
  ;;       margin: 0px;\n
  ;;     }"

)

This is pretty cool, so why don't we add a function to generate CSS for multiple elements at a time?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn el->css ...)

(defn els->css
  "Given an collection of elements (or Clojure representations of elements),
   returns CSS rules corresponding to the elements' IDs."
  [els]
  (->> els
       (map el->clj)
       (remove (comp empty? :styles))
       (map el->css)
       (str/join "\n\n")))

;; (defn create-el ...)
;; ...

(comment

  ;; ...

  (->> (els->css (get-els ".cljcastr"))
       log)
  ;; => "#episode-container {\n
  ;;       width: 100%;\n
  ;;       display: flex;\n
  ;;       flex-direction: column;\n
  ;;       align-items: center;\n
  ;;     }\n
  ;;     \n
  ;;     #episode-info {\n
  ;;       max-width: 420px;\n
  ;;       text-align: center;\n
  ;;     }\n
  ;;     \n
  ;;     ...

)

Casting our gaze upon the console, joy fills our hearts:

CSS code

However, (and you knew the however was coming, eh dear reader?) our heart rankles at the repetition inherent in the system.

It’s your birthday. Someone gives you a calfskin wallet.

What people do when they write CSS is use classes to combine related sets of elements. We're not going to be able to do that completely programmatically, but we can at least spit out something that's a better starting point for a person to pick up and shape to their liking.

How might we go about this?

(comment

  ;; ...

  (def style-groups
    (->> (get-els ".cljcastr")
         (map el->clj)
         (remove (comp empty? :styles))
         (group-by :styles)))
  ;; => #'cljcastr.player/style-groups

  (count style-groups)
  ;; => 13

  (->> style-groups
       (map (fn [[_styles els]]
              (map #(select-keys % [:tag :id :classes]) els))))
  ;; => (({:tag "div", :id "progress-container", :classes ("cljcastr")})
  ;;     ({:tag "div", :id "progress", :classes ("cljcastr")})
  ;;     ({:tag "progress", :id "progress-bar", :classes ("cljcastr")})
  ;;     ({:tag "img", :id "episode-art", :classes ("cljcastr")})
  ;;     ({:tag "input", :id "progress-slider", :classes ("cljcastr")})
  ;;     ({:tag "span", :id "episode-date", :classes ("cljcastr")})
  ;;     ({:tag "div", :id "episode-info", :classes ("cljcastr")})
  ;;     ({:tag "img", :id "button-backward-icon", :classes ("cljcastr" "playback-icon")}
  ;;      {:tag "img", :id "button-play-icon", :classes ("cljcastr" "playback-icon")}
  ;;      {:tag "img", :id "button-pause-icon", :classes ("cljcastr" "playback-icon")}
  ;;      {:tag "img", :id "button-forward-icon", :classes ("cljcastr" "playback-icon")}
  ;;      {:tag "img", :id "button-speed-icon", :classes ("cljcastr" "playback-icon")})
  ;;     ({:tag "audio", :classes ("cljcastr")})
  ;;     ({:tag "span", :id "elapsed-time", :classes ("cljcastr")})
  ;;     ({:tag "button", :id "button-backward", :classes ("cljcastr" "playback-button")}
  ;;      {:tag "button", :id "button-play", :classes ("cljcastr" "playback-button")}
  ;;      {:tag "button", :id "button-pause", :classes ("cljcastr" "playback-button")}
  ;;      {:tag "button", :id "button-forward", :classes ("cljcastr" "playback-button")}
  ;;      {:tag "button", :id "button-speed", :classes ("cljcastr" "playback-button")})
  ;;     ({:tag "span", :id "remaining-time", :classes ("cljcastr")})
  ;;     ({:tag "div", :id "episode-container", :classes ("cljcastr")}))

)

What we're doing here is getting all the elements having "cljcastr" as one of their classes, turning them into Clojure data structures, removing the ones with no inline styles, then grouping them by their styles. This results in a map where the keys are styles and the values are the list of the elements with those styles (see the documentation for the group-by function if this is confusing). We then map over that to look at the tag, ID, and classes of the elements with each unique set of styles.

Grouping elements like this seems useful, so let's create a function to do it:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn el->clj ...)

(defn group-styles
  "Given a collection of DOM elements, returns a map keyed by styles. The value
   at each key will be a vector of the corresponding elements, in the order they
   appeared in the original list.

   Example:

     (let [els [(create-el {:tag \"div\", :id \"el1\", :styles {:color \"red\"}})
                (create-el {:tag \"div\", :id \"el2\", :styles {:color \"blue\"}})
                (create-el {:tag \"div\", :id \"el3\"})
                (create-el {:tag \"div\", :id \"el4\", :styles {:color \"red\"}})]]
       (->> (group-styles els)
            (map (fn [[styles els]]
                   [styles (map #(select-keys % [:id]) els)]))))
     ;; => ([{:color \"red;\"} ({:id \"el1\"} {:id \"el4\"})]
     ;;     [{:color \"blue;\"} ({:id \"el2\"})])"
  [els]
  (->> els
       (map el->clj)
       (remove (comp empty? :styles))
       (group-by :styles)))

;; (defn attrs->str ...)
;; ...

Now that we have this, let's see how many of these styles apply to multiple elements:

(comment

  ;; ...

  (->> (get-els ".cljcastr")
       group-styles
       (filter (fn [[_ els]] (> (count els) 1)))
       (map (fn [[_styles els]]
              (map #(select-keys % [:tag :id :classes]) els))))
  ;; => (({:tag "img", :id "button-backward-icon", :classes ("cljcastr" "playback-icon")}
  ;;      {:tag "img", :id "button-play-icon", :classes ("cljcastr" "playback-icon")}
  ;;      {:tag "img", :id "button-pause-icon", :classes ("cljcastr" "playback-icon")}
  ;;      {:tag "img", :id "button-forward-icon", :classes ("cljcastr" "playback-icon")}
  ;;      {:tag "img", :id "button-speed-icon", :classes ("cljcastr" "playback-icon")})
  ;;     ({:tag "button", :id "button-backward", :classes ("cljcastr" "playback-button")}
  ;;      {:tag "button", :id "button-play", :classes ("cljcastr" "playback-button")}
  ;;      {:tag "button", :id "button-pause", :classes ("cljcastr" "playback-button")}
  ;;      {:tag "button", :id "button-forward", :classes ("cljcastr" "playback-button")}
  ;;      {:tag "button", :id "button-speed", :classes ("cljcastr" "playback-button")}))

)

These look like great candidates for consolidation! But what selector should we use to write the rule?

Well, we know that all of our elements share the "cljcastr" class in common, so if we exclude that, we see that both of these groups are left with a single class!

(comment

  ;; ...

  (->> (get-els ".cljcastr")
       group-styles
       (filter (fn [[_ els]] (> (count els) 1)))
       (map (fn [[_styles els]]
              (map (fn [el]
                     (-> (select-keys el [:tag :id :classes])
                         (update :classes #(remove (fn [cls] (= "cljcastr" cls)) %))))
                   els))))
  ;; => (({:tag "img", :id "button-backward-icon", :classes ("playback-icon")}
  ;;      {:tag "img", :id "button-play-icon", :classes ("playback-icon")}
  ;;      {:tag "img", :id "button-pause-icon", :classes ("playback-icon")}
  ;;      {:tag "img", :id "button-forward-icon", :classes ("playback-icon")}
  ;;      {:tag "img", :id "button-speed-icon", :classes ("playback-icon")})
  ;;     ({:tag "button", :id "button-backward", :classes ("playback-button")}
  ;;      {:tag "button", :id "button-play", :classes ("playback-button")}
  ;;      {:tag "button", :id "button-pause", :classes ("playback-button")}
  ;;      {:tag "button", :id "button-forward", :classes ("playback-button")}
  ;;      {:tag "button", :id "button-speed", :classes ("playback-button")}))

)

Let's run those through els->css and see what we get:

(comment

  ;; ...

  (->> (get-els ".cljcastr")
       group-styles
       (filter (fn [[_ els]] (> (count els) 1)))
       (map (fn [[_styles els]]
              (->> els
                   (map (fn [el]
                          (update el :classes #(remove (fn [cls] (= "cljcastr" cls)) %))))
                   els->css)))
       (str/join "\n\n")
       log)
  ;; => "#button-backward-icon {\n  width: 35px;\n  ...}"

)

Looking at the console, we see that we're back where we started:

#button-backward-icon {
  width: 35px;
  filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}

#button-play-icon {
  width: 35px;
  filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}

#button-pause-icon {
  width: 35px;
  filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}

#button-forward-icon {
  width: 35px;
  filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}

#button-speed-icon {
  width: 35px;
  filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}

#button-backward {
  padding: 0px;
  margin: 0px;
  border: 0px;
  background: transparent;
  cursor: pointer;
}

#button-play {
  padding: 0px;
  margin: 0px;
  border: 0px;
  background: transparent;
  cursor: pointer;
}

#button-pause {
  padding: 0px;
  margin: 0px;
  border: 0px;
  background: transparent;
  cursor: pointer;
}

#button-forward {
  padding: 0px;
  margin: 0px;
  border: 0px;
  background: transparent;
  cursor: pointer;
}

#button-speed {
  padding: 0px;
  margin: 0px;
  border: 0px;
  background: transparent;
  cursor: pointer;
}

What we really want to do is generate a single rule for each group, with the remaining class as the selector. If we update el->css with a second arity that accepts a selector as an argument, we can accomplish this fairly easily:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn format-css ...)

(defn el->css
  "Given an element (or the Clojure representation of an element), returns a CSS
   rule corresponding to the element's ID."
  ([el]
   (let [el (if (map? el) el (-> (get-el el) el->clj))
         {:keys [styles id tag classes]} el
         sel (if (not-empty id)
               (str "#" id)
               (str tag (if (seq classes)
                          (str "." (first classes)))))]
     (el->css el sel)))
  ([el sel]
   (format-css sel (:styles el))))

;; (defn els->css ...)
;; ...

(comment

  ;; ...

  (->> (get-els ".cljcastr")
       group-styles
       (filter (fn [[_ els]] (> (count els) 1)))
       (map (fn [[_styles [el & _]]]
              (let [sel (str "." (->> (:classes el)
                                      (remove (partial = "cljcastr"))
                                      first))]
                (el->css el sel))))
       (str/join "\n\n")
       log)
  ;; => ".playback-icon {\n  width: 35px;\n  ...}"

)

And now the console fills our hearts with delight once again!

.playback-icon {
  width: 35px;
  filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}

.playback-button {
  padding: 0px;
  margin: 0px;
  border: 0px;
  background: transparent;
  cursor: pointer;
}

Let's now take a step back and think about how we want to generate the rules:

  1. Group by styles
  2. For groups with a single element, just call el->css on it
  3. For groups with multiple elements, take the first one, drop "cljcastr" from its list of classes, generate a selector from the first remaining class, and then call el->css on the element, using the custom selector

And if we say this in Clojure, it looks like this:

(comment

  ;; ...

  (->> (get-els ".cljcastr")
       group-styles
       (map (fn [[_styles [el & rest-els]]]
              (if (seq rest-els)
                (let [sel (str "." (->> (:classes el)
                                        (remove (partial = "cljcastr"))
                                        first))]
                  (el->css el sel))
                (el->css el))))
       (str/join "\n\n")
       log)
  ;; => "#progress-container {\n  position: relative;\n  height: 28px;\n  ...}"

)

If we look at the console, it appears that all of our prayers are answered! 🎉

#progress-container {
  position: relative;
  height: 28px;
  clear: both;
}

#progress {
  width: 390px;
  margin: 1.5em auto 0px;
  position: relative;
}

#progress-bar {
  color: rgb(247, 147, 30);
  top: 10px;
  overflow: hidden;
  background-color: lightgray;
  width: 100%;
  z-index: 0;
  appearance: none;
  position: absolute;
  border: 0px;
  border-radius: 4px;
  height: 8px;
  left: 0px;
  margin: 0px;
}

#episode-art {
  width: 400px;
  border-radius: 5%;
  padding: 10px;
}

#progress-slider {
  top: 0px;
  background-color: transparent;
  width: 100%;
  cursor: pointer;
  z-index: 1;
  appearance: none;
  position: absolute;
  border: 0px;
  touch-action: manipulation;
  height: 28px;
  left: 0px;
  margin: 0px;
}

#episode-date {
  color: grey;
}

#episode-info {
  max-width: 420px;
  text-align: center;
}

.playback-icon {
  width: 35px;
  filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}

audio.cljcastr {
  display: none;
}

#elapsed-time {
  margin: 0px 5px;
  font-size: 0.7rem;
  color: grey;
  float: left;
}

.playback-button {
  padding: 0px;
  margin: 0px;
  border: 0px;
  background: transparent;
  cursor: pointer;
}

#remaining-time {
  margin: 0px 5px;
  font-size: 0.7rem;
  color: grey;
  float: right;
}

#episode-container {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
}

Now that we have all of these lovely styles, we can write a couple of functions to export them, following the same pattern as for save-html!:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn save-html! ...)

(defn dom->css []
  (->> (get-els ".cljcastr")
       group-styles
       (map (fn [[_styles [el & rest-els]]]
              (if (seq rest-els)
                (let [sel (str "." (->> (:classes el)
                                        (remove (partial = "cljcastr"))
                                        first))]
                  (el->css el sel))
                (el->css el))))
       (str/join "\n\n")
       log))

(defn save-css!
  ([]
   (save-css! "main.css"))
  ([filename]
   (save-css! filename (dom->css)))
  ([filename css]
   (save-file! filename css "text/css")))

;; (def seek-dur-sec ...)
;; ...

(comment

  ;; ...

  (save-css!)
  ;; => nil

)

We evaluate the save-css! form and save the main.css file alongside our index.html. Now all we have to do to use the styles is two things:

  1. Import the stylesheet in index.html
  2. Regenerate our HTML without the inline styles

Inline skating away on the thin ice of a new day

The first part is simple, we just add one element to the DOM:

(comment

  ;; ...

  (->> (create-el {:tag "link",
                   :attrs {:rel "stylesheet", :href "main.css"}})
       (add-child! "head"))
  ;; => #object[HTMLHeadElement [object HTMLHeadElement]]

  )

And now for the new HTML. Let's update el->html to take a new option called :exclude-styles?, and then emit the HTML without inline styles if it's truthy:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn indent ...)

(defn el->html
  "Given an element (or the Clojure representation of an element), returns an
   HTML string."
  ([el]
   (el->html el {}))
  ([el {:keys [indent-level indent-str exclude-styles?]
        :or {indent-level 0, indent-str "  "}
        :as opts}]
   (if (instance? js/CharacterData el)
     (str/trim (get-text el))
     (let [{:keys [tag attrs]} (if (map? el) el (el->clj el))
           attrs (if exclude-styles? (dissoc attrs :style) attrs)
           attrs-str (attrs->str attrs)
           ;; Reset indentation for <head> and <body> tags
           indent-level (if (#{"head" "body"} (str/lower-case tag)) 0 indent-level)
           children-html (->> (get-children el opts)
                              (map #(el->html % (assoc opts :indent-level
                                                       (inc indent-level))))
                              (str/join ""))
           tag-open (str (indent indent-level indent-str)
                         "<" tag
                         (if (empty? attrs-str) "" (str " " attrs-str))
                         ">"
                         (if (str/ends-with? children-html "\n")
                           "\n"
                           ""))
           tag-close (str (if (str/ends-with? children-html "\n")
                            (indent indent-level indent-str)
                            "")
                          "</" tag ">\n")]
       (str tag-open children-html tag-close)))))

;; (defn format-css ...)
;; ...

(comment

  ;; ...

  (->> (get-els "body > .cljcastr")
       (map #(el->html % {:exclude-styles? true}))
       (str/join "\n")
       log)
  ;; => "<div id=\"episode-container\" class=\"cljcastr\">\n
  ;;       <div id=\"episode-info\" class=\"cljcastr\">\n
  ;;         <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
  ;;         <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
  ;;         <span id=\"episode-date\" class=\"cljcastr\">July 9, 2025</span>\n
  ;;       </div>\n
  ;;       ...
  ;;     </div>\n
  ;;     "

)

Let's update dom->html and save->html! to allow us to exclude styles if we wanna. But how to do it? dom->html is simple: we just add a 1-arity version of the function that takes an options map. save-html! on the other hand requires a tiny bit of design. We could add yet another arity, but then we have the problem of distinguising between what the arguments mean. Let's simplify a bit: we'll have the 0-arity function to fill in all the defaults, and a 1-arity version taking an options map:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn keep-child? ...)

(defn dom->html
  ([]
   (dom->html {}))
  ([opts]
   (el->html "html"
             (merge opts
                    {:filter-children keep-child?}))))

(defn save-html!
  ([]
   (save-html! {}))
  ([{:keys [filename html]
     :or {filename "index.html"}
     :as opts}]
   (let [html (or html (dom->html opts))]
     (save-file! filename html "text/html"))))

;; (defn save-html! ...)
;; ...

Now we can save a new version of index.html, this time without the styles:

(comment

  ;; ...

  (save-html! {:exclude-styles? true})
  ;; => nil

  )

After saving, let's check index.html to make sure things look good:

<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title>cljcastr episode player</title>
  <link rel="stylesheet" href="main.css"></link>
  <script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js" type="application/javascript"></script>
  <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>
  <script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js" type="application/javascript"></script>
  <script type="application/x-scittle" src="player.cljs"></script>
</head>
<body>
  <div id="episode-container" class="cljcastr">
    <div id="episode-info" class="cljcastr">
      <h3 id="podcast-title" class="cljcastr">defn</h3>
      <h2 id="episode-title" class="cljcastr">876b4c306172 Cora Sutton</h2>
      <span id="episode-date" class="cljcastr">July 9, 2025</span>
    </div>
    ...
  </div>
</body>
</html>

Holding our breath, we reload the page, and... see the same thing we were just looking at. 😅

But actually, that's precisely what we were trying to achieve. If we open dev tools and inspect the play button, we see two exciting things:

  1. No inline styles in the button element
  2. In the Styles panel, there is a .playback-button class being applied, defined in main.css

Player with just buttons

At this point, we've built a nice podcast player interactively, then created a persistence mechanism so we can safely reload the page. This feels really good, right? 😀

We bask in the feeling for an altogether brief moment, then start thinking of ways to improve things. Like adding that transcript we were talking about many thousands of word ago? Or maybe not limiting ourselves to a single defn episode (even if it is a 🔥 episode!)?

Our minds start to fill with the possibilities, but we come to our senses and decide some hammock time (in the form of a nice long walk perhaps—maybe even with one of our favourite human or canine companions?) is in order.

Next time on Scittlecastr, we return from our hammock time and get to scittling them features!

The preview image for this post was based on the Teenager looking at equation on chalk board photo by Rafa Fernandez from his Back To School Boy photoshoot. The photo is used under The Noun Project's Royalty-Free Commercial License, and was remixed by hand in the GIMP, which does not allow genAI contributions.

Clojurists Together Update: September and October 2026

Time for another bi-monthly update on the work that Clojurists Together are funding this year - my maintenance of nREPL, CIDER and friends. You can find the previous one here.

For a change, most of my focus in September and October was on nREPL itself, not on CIDER. The big themes were the nREPL protocol spec and how well the servers and clients out there actually stick to it. For years building an nREPL server or client that plays well with the rest of the ecosystem came down to trial and error, and this time I finally did something about it. It paid off much sooner than I expected.

The big highlights from my perspective:

  • proof, a compatibility suite for nREPL servers and clients, is out, and it’s already found (and helped fix) problems in nREPL itself, Babashka, CIDER, Calva, Conjure and many other servers and clients
  • neorepl, a brand new terminal REPL meant to be something like “CIDER in a terminal” and “CIDER for agents”, went from 0.1 to 0.4 in two weeks
  • nREPL 1.8 is out, and master already has fixes for some of the bugs proof found in it
  • Piggieback 0.7.1 and 0.8.0 fixed a few long-standing problems with ClojureScript REPLs
  • nrepl.org became a proper landing page (with an nREPL server running right in your browser), the manual moved to docs.nrepl.org, and CIDER got a landing page of its own at cider.mx
  • CIDER’s master gained streaming test results and commands for editing the ns form, backed by new cider-nrepl and Orchard releases
Read moreShow less

Below you’ll find the details, project by project.

proof and the nREPL spec

proof is the big new project this time around, and the one I’m most excited about. I’ve been talking about some automated way to check nREPL compatibility for ages (it made my list of things people hate about nREPL back in 2023), so it feels really great to finally see it become a reality. Good things take a while to happen, they said… Does your server work with CIDER? Until now the only way to find out was to implement the protocol, connect CIDER (or Calva, or whatever your users run) and see what breaks. Now you can point proof at your server and it’ll tell you. It’s a single binary that runs about 80 checks against a server. A check fails only when a real client would break (with a link to the client code that depends on the behavior), and everything else is just a warning. proof also comes with client profiles that send exactly what CIDER, Calva, Conjure, vim-fireplace, REPLy and neorepl send, profiles for the servers I know of, and a GitHub Action for running it in a server’s CI.

Once the server side was in place, I started on the clients as well. proof proxy sits between a client and a server and checks what the client sends, and proof serve is a small server for the test suites of clients, which can mimic the quirks of nREPL, Babashka, jank and others (proof serve -like jank). I wrote about proof in a lot more detail in proof: How Strong Is Your nREPL?.

proof goes hand in hand with the nREPL spec, the draft Phil Hagelberg wrote earlier this year. When the spec and the existing clients disagree, proof goes with the clients (a spec that breaks CIDER isn’t much use to anyone), and every such conflict, along with the gaps proof ran into, ends up on a list of spec changes. The first few of them are already PRs against the spec: how replies relate to their requests, the status values, how interrupts are answered and the fields of lookup replies.

What I like the most about proof is how quickly it started paying off:

  • nREPL itself sent the done of an eval interrupted from another connection to the wrong connection (fixed on master), drops the evals waiting behind an interrupted one (#503) and can’t interrupt an eval that hasn’t started yet (#504). Not great!
  • Between proof and neorepl, Babashka got a couple of dozen issue reports from me in October, from test ops that never answered to a session that stopped answering after its client went away in the middle of an eval. Michiel Borkent fixed nearly all of them the same day or the next. You rock, Michiel!
  • CIDER didn’t finish setting up connections to servers without cider-nrepl (fixed), Calva and Conjure didn’t sort the keys of the dictionaries they sent (both fixed within days), vim-fireplace and REPLy never close their sessions, and REPLy’s and Conjure’s interrupts don’t say which eval to stop.
  • Basilisp and jank got a few reports as well (lookup replies, and a request that throws taking its whole connection down).
  • My own toy implementations (nrepl-beam, mezcaml and neat) were the first projects I ran proof against (more on them below).

I was also super happy to see other people using proof already. Tom Waddington has been running it against his nREPL implementations for Janet and Steel Scheme since early October (nrepl-janet even tests its client with proof serve), Jeaye Wilkerson suggested using it for jank’s nREPL server, and Kyle Cesare is already fixing what it found there. That’s exactly what I was hoping for!

neorepl

neorepl is the other brand new project, and it grew up together with proof. It’s a terminal REPL for Clojure (and anything else that runs an nREPL server), and the best way I can describe it is “CIDER in a terminal”: it uses cider-nrepl when it’s around, the same way CIDER does, and falls back to nREPL’s built-in ops when it isn’t. Every lookup the REPL can do is also a command with structured output, and there’s an MCP server and a skill for coding agents, so it’s “CIDER for agents” as well. Five releases in three weeks:

  • neorepl 0.1.0 had the REPL with a proper line editor (highlighting, completion, arglists as you type, history per project, a Ctrl-C that actually interrupts), CIDER-style REPL commands, jack-in for Leiningen, the Clojure CLI, Babashka and shadow-cljs, and commands for evaluating code and looking things up that keep their session between calls. It runs on the JVM and on Babashka.
  • neorepl 0.2.0 added a test runner (Clojure and ClojureScript), a full-screen inspector and a stacktrace browser.
  • neorepl 0.3.0 was all about coding agents: neorepl mcp, a skill that teaches agents to check, load, test and debug Clojure code with the CLI, and neorepl check --fix, which fixes mismatched delimiters the way parinfer does.
  • neorepl 0.4.0 brought tracing, switching the REPL into another named session (e.g. to take a look at what your coding agent is doing), and a huge list of fixes. I wrote about it (and the two releases before it) in neorepl 0.4: Tests, Inspector, Tracing and Agents.
  • neorepl 0.4.1 caught up with cider-nrepl 0.64.

proof checks every server with the exact requests neorepl sends, and neorepl went through proof proxy and every scenario of proof serve before 0.1 was out. Its commands are separate processes sharing a session, so neorepl hits nREPL’s interrupt bugs harder than most clients, and making it work as well on Babashka’s server as with cider-nrepl is where most of the Babashka reports came from. Even JLine got a few bug reports along the way.

By the time I got around to writing this update, neorepl 0.5 was out as well, with a much smarter line editor, a namespace browser, automatic reloading and a CLI that editors can use without a plugin. I wrote about it in neorepl 0.5: CIDER in a Terminal.

nREPL

nREPL 1.8.0 came out on October 2 with the URL-based connections and the TLS improvements I mentioned last time, plus a few session fixes. Sessions survive evaluated code closing *in* or *out*, a race that could split responses between concurrent reads in nrepl.core/client is gone, and Sashko made persistent sessions re-create their thread when a faulty exec thunk kills it. He also moved the Unix sockets to the SocketChannel API.

Master moved on quickly after the release, much of it because of proof and neorepl:

  • the done of an eval interrupted from another connection goes to the eval’s own connection, where the client that sent the eval never got it
  • the server sets TCP_NODELAY on accepted connections, so on Linux an eval’s done no longer waits up to 40ms behind its value
  • Sashko replaced :out-limit with separate :out-quota and :err-quota limits, made the bencode transport turn empty lists into nil for ?-ending keys and made building the middleware stack faster

The interrupt bugs that are still open are next on my list.

Piggieback

  • Piggieback 0.7.1 fixed load-file failing with “No such namespace” when the file requires a foreign lib (e.g. a cljsjs package).
  • Piggieback 0.8.0 fixed :cljs/quit on nREPL 1.3+, where the session stayed in ClojureScript mode, and made evaluation use the same dynamic bindings as cljs.repl. That means the cljs-repl options (e.g. :warnings) and the bindings tools like figwheel-main set up around it finally take effect, which closes an issue from 2018. It also picked up :as-alias keywords and the :cljs branch of data_readers.cljc.

nrepl.org and cider.mx

nrepl.org got a facelift at the end of October. The manual moved to docs.nrepl.org, and nrepl.org itself is now a landing page that explains what nREPL is and how its protocol works, with real exchanges between a client and a server next to every feature. It even has a “Try it” section with an nREPL server running right in your browser, which turned out to be a story of its own. That playground is how nrepl-sci, a small nREPL server for SCI that runs entirely in a web page, came to be, and it even led me to take a closer look at clojure-mode for CodeMirror, which powers the playground’s editor.

CIDER got a landing page of its own at cider.mx, made to look like an Emacs frame (mode line, M-x and all), with mini screencasts of CIDER in action. I had a lot of fun working on both sites!

neat, mezcaml and nREPL on the BEAM

My toy nREPL implementations were the first things I ran proof against, and all of them got a round of fixes in early October:

  • dialtone and repartee, the Erlang and Elixir servers in nrepl-beam, now answer close with session-closed, echo the op in unknown-op replies and return a value for every Erlang form in an eval
  • mezcaml no longer lets late output from one eval end the next one early, interrupts on Ctrl-C, answers stdin requests, closes its session on exit and completes on jank and Basilisp
  • neat shows late and id-less output, reports error statuses that come without err, handles unknown sessions and stdin EOF, and checks describe before using completions, lookup and interrupt

neat’s own integration suite now tests just the client and leaves the servers to proof. None of the three has had a release yet, though.

CIDER

No CIDER release this time (2.1 is still brewing), but master kept moving:

  • Test results now stream in while the tests run (cider-test-stream-results, on by default): the echo area keeps a running count, and failing tests land in the report as soon as they finish. This closes an issue from 2014! cider-test-fail-fast is now off by default to go with it.
  • A family of commands for editing the ns form: cider-ns-add-require, cider-ns-add-import, cider-ns-add-missing (adds what the symbol at point needs), magic requires (typing alias/ adds the require the alias needs) and cider-ns-clean. These are some of clj-refactor’s most popular features, and they’re backed by new cider-nrepl ops that read your code instead of loading it.
  • Connecting to a server without cider-nrepl (e.g. plain nREPL or Babashka) finishes setting up the connection again, where cider-connected-hook and friends never ran. proof found this one.
  • Keys with a nil value are left out of nREPL requests instead of being sent as empty lists, which closes an issue from 2020.
  • Natively compiled builds no longer have broken menu options, Emacs 31 is on CI, and the manual got per-build-tool setup tabs, the refcard and CI checks for its cross references.

cider-nrepl and Orchard

cider-nrepl releases:

  • cider-nrepl 0.63.0 runs tests with orchard.test and streams their progress to the client, which is what CIDER’s test streaming builds on.
  • cider-nrepl 0.63.1 was Sashko’s quick bump to compliment 0.8.2.
  • cider-nrepl 0.64.0 added the cider/resolve-missing, cider/resolve-alias and cider/clean-ns ops behind CIDER’s ns commands (after three alphas), made the test ops always answer and send what tests print with their own responses on nREPL 1.3+, and fixed the inspector and ClojureScript tests on shadow-cljs.

Orchard releases:

  • Orchard 0.45.0 added orchard.test, the clojure.test runner that used to live in cider-nrepl and can now report progress as it goes, plus Sashko’s string analytics in the inspector and his fix for the stack overflows when printing recursive collections.
  • Orchard 0.46.0 and Orchard 0.47.0 added orchard.ns-form, which takes apart an ns form and tells what it requires, refers and imports that the code doesn’t use, all without loading anything, and fixed some Java doc rendering problems.
  • Orchard 0.48.0 made tracing show calls that throw and stopped tracing a function tracing itself uses (say, str) from overflowing the stack.

Sashko also added JDK 27 to the CI of nREPL, cider-nrepl and Orchard. Thanks, Sashko!

refactor-nrepl and clj-refactor

refactor-nrepl 3.15.0 documented the limitations of its AST-based analysis and deprecated the ops cider-nrepl has taken over. Back in the spring I said I’d like to move the most useful refactor-nrepl features to CIDER and cider-nrepl eventually, and the ns editing commands above are the first step in that direction.

clojure-ts-mode

No release, but quite a few fixes on main. I fixed clojure-ts-mode on Emacs 31 (e.g. the ClojureScript mode asking for the jsdoc grammar, M-q indenting only the innermost form) and a pile of refactoring commands that went wrong around metadata, #_ forms and reader conditionals. Jason Jackson sent a round of indentation and alignment fixes (threading macros, reader prefixes, metadata and comments before arguments), and Kyle Cesare kept up with jank’s syntax. Thanks, Jason and Kyle!

MrAnderson and smaller things

MrAnderson 0.7.2 stopped bundling pomegranate’s Maven classes in its jar, where they clashed with any other resolver on the classpath and broke cider-nrepl’s build once tools.build pulled in a newer tools.deps. The artifact went from 8 MB to about 200 KB in the process.

There was also a lot of release and CI maintenance across nREPL, cider-nrepl, Orchard, Piggieback and friends (e.g. release notes taken from the changelogs, the docs of a release published by its release workflow), and I revived the CI of parseclj and parseedn.

Blog posts

Epilogue

Big thanks to Clojurists Together, Nubank and the other organizations and people supporting my Clojure OSS work! None of this would have happened without you. You rock!

As for what’s next - last time I said CIDER 2.1 was the obvious next milestone. It still is, but it got delayed, as most of my time went into nREPL instead. I told you I’m bad at predictions! nREPL 1.8 shipped as planned, and mezcaml and the BEAM servers got a lot more reliable thanks to proof, even if neither got a release. While we’ve made solid progress on CIDER and friends as well, my main focus until the end of the year will likely be the broader nREPL ecosystem: fixing the interrupt bugs proof found in nREPL, turning the list of spec changes into PRs against the spec, and getting more servers and clients to run proof.

Keep hacking!

proof: How Strong Is Your nREPL?

For as long as I’ve been involved with nREPL, the question I’ve heard most often from people building servers and clients has been some variation of “Will this work with CIDER?”. And for just as long the honest answer was “Try it and see”. Today I have a better answer for you - proof, a compatibility suite for nREPL servers (and clients). I’ve been working on it since the end of September and proof 0.2 came out a couple of days ago, so I guess it’s high time I told you a bit about it.

Try It and See

The nREPL protocol is tiny. A handful of ops, requests and responses that are simple maps, bencode on the wire. You can write a basic nREPL server in a couple of hours, and lots of people have done exactly that. Making it play nice with CIDER, Calva, Conjure and the rest of the clients out there is a very different matter, though. What clients actually expect from a server is spread across the reference implementation, a draft spec, many years of client code and lots of GitHub issues. Which status values mean what? Is ops in describe a map or a list? Where does the end of an interrupted evaluation go? What happens to a session when its client goes away in the middle of an eval? Server authors usually find out one bug report at a time.

Read moreShow less

Client authors don’t have it any easier, as every server has its own quirks, and for many years the only way to find them was to try your client with every server you could get your hands on. I’ve been on both sides of this conversation for well over a decade (CIDER is a client, nREPL is a server), so believe me - I know the pain.

Bigger Than Clojure

Why bother? Because I’ve always seen nREPL as something bigger than Clojure. Back in 2019 I wrote that nREPL is similar in nature to LSP, only much simpler, and I still believe that. For any language that can evaluate code at runtime, nREPL is a much simpler alternative to LSP. Implement eval, interrupt and load-file, throw in completions and lookups if you feel like it, and every nREPL client out there becomes an editor integration for your language.

That’s not just a dream anymore. These days there are nREPL servers for Clojure dialects running on Python (Basilisp), C++ (jank), .NET (ClojureCLR), Go (let-go) and Node.js (nbb), for Janet and Steel Scheme, and even for Erlang and Elixir (more on those later). Still, “language-agnostic” doesn’t mean much if everyone who builds a server has to check it against CIDER by hand.

Better Late Than Never

The idea of a compatibility suite is not new. When nREPL 1.0 came out in 2022 I wrote that improving the protocol spec and the ability to test against it was where our focus should go next, and a year later the lack of a compatibility test suite made my list of things people hate about nREPL. I even asked if one of you was looking for a fun small OSS project. Nobody volunteered, and the idea spent a few more years on the backburner. This year, thanks to the support of Clojurists Together, I finally made a very focused push on it.

It started with the spec. Late last year Phil Hagelberg wrote a draft of the nREPL protocol spec, which landed in May. A spec is great, but you can’t run a spec against your server, so I started proof. The two feed each other - every time proof finds a place where the spec and the clients disagree, or something the spec doesn’t cover at all, it ends up in a running list, and from there in the spec itself. (A few of those are already pull requests against it.)

When the spec and the clients disagree, proof sides with the clients. For instance, the spec recommends returning the ops in describe as a list, but CIDER and Calva both expect a map. A server that follows the spec to the letter, but that CIDER can’t connect to, is not much use to anyone. That’s also why proof is a compatibility suite and not a conformance suite. You can only conform to an official spec, and nREPL doesn’t have one yet.

Checking Servers

Checking servers is proof’s main job. It’s a single binary1 that talks to your server over a socket, the same way any client would. It knows nothing about the server’s internals and it doesn’t care what language the server is written in. What it does need is a profile - a small TOML file that tells proof how to start your server and gives it a few snippets of code in the server’s language (an idea I borrowed from jupyter_kernel_test):

name = "My server"

[launch]
command = ["my-server", "--port", "0"]
port-pattern = 'nREPL server started on port (\d+)'

[snippets.value]
code = "(+ 1 2)"
value = "3"

Then you install proof and run it:

$ go install github.com/nrepl/proof/cmd/proof@latest
$ proof run my-server.toml

proof starts your server, runs about 80 checks against it and shuts it down afterwards. (You can also grab a binary from the releases, if you don’t have Go around.) Profiles for nREPL, Babashka, nbb, Basilisp, ClojureCLR, jank, let-go, dialtone and repartee come with proof, so proof run babashka just works if you have Babashka installed.

The most important decision in proof’s design is how it grades the checks. A check fails only when some client won’t work properly with your server (or when users will lose data), and the report tells you which client it is and links to the client code in question, pinned to a specific commit. If your server simply does something differently from the reference implementation, you get a warning instead. Here’s a failure from a run against Babashka 1.13.226:

FAIL   interrupt.other-connection   An interrupted eval ends on its own connection
       the interrupted eval's done went to the connection the interrupt came from
       why: neorepl interrupt stops an eval that another neorepl process
       started, and that process waits for the eval's done on its own
       connection forever if it goes anywhere else.
       see: neorepl interrupt stops an eval from another process
       https://github.com/nrepl/neorepl/blob/462a182c.../src/neorepl/main.clj#L374-L385
       see: nrepl/nrepl#498 https://github.com/nrepl/nrepl/issues/498

The idea is that a failure should always be worth fixing and never be a matter of taste. If you think some failure isn’t justified, the links show you exactly what you need to argue with.

Some checks go a step further and send exactly what a particular client sends, the way it sends it. cider.connect sends the requests CIDER sends while connecting, calva.eval evaluates code the way Calva does, and so on for Conjure, vim-fireplace, REPLy, neorepl, clojure-cli.repl and bb repl --connect. That way the report tells you directly whether those clients will work with your server.

Once your server passes everything it can pass, you can list the failures you can’t fix yet as expected failures in its profile and run proof in your CI with its GitHub Action. When an expected failure starts passing, proof fails the build and asks you to remove it from the list, so the list can’t go stale.

Last, but not least, the bundled profiles run every day and their reports end up in a compatibility matrix, which shows where the different servers agree, where they don’t and where the spec needs some work.

Checking Clients

Servers were always proof’s primary focus, but once the foundation was in place people started asking me how they could check their clients with it. That made a lot of sense to me - after all, the same knowledge cuts both ways. proof has two tools for client authors.

proof proxy sits between a client and a server, passes everything along as is and records it on the way. You use your client for a while (or run its test suite through the proxy), press Ctrl-C and proof grades every request the client sent. Here’s a quick neorepl eval going through it:

$ proof proxy -listen 127.0.0.1:7888 babashka
$ neorepl eval --port 7888 '(println "hi") (+ 1 2)'
client
  PASS   client.bencode               Every frame is valid bencode
  PASS   client.dict                  Every request is a dict
  PASS   client.canonical             Frames use canonical bencode
  PASS   client.id                    Every request has an id
  PASS   client.active-id             An id isn't reused while its request is active
  PASS   client.field-types           Request fields have the right types
  PASS   client.required-fields       Requests carry the fields their op needs
  PASS   client.need-input            need-input is answered with stdin in the same session
  WARN   client.close                 Sessions are closed before disconnecting
  PASS   client.unknown-op            Requests use ops the server supports

9 passed, 0 failed, 1 warnings, 0 skipped

The grading rule is the same, just turned around. A client check fails only when some server breaks, and it links to the server code in question. (That warning is fine, by the way. neorepl keeps its session around on purpose, so the next neorepl eval can use it.)

A proxy sees only what goes over the wire, though, so it can’t tell you how your client copes with the replies of different servers. That’s what proof serve is for. It’s a small nREPL server for client test suites, which behaves like nREPL unless you ask it to behave like some other server in some particular way - send only the value of the last form (like Basilisp, jank and dialtone), drop what the code prints to stderr, have no interrupt op, deliver its replies a byte at a time, and so on. None of those scenarios are made up. Every one of them is something a real server does (or something TCP can do to any server’s replies). -like turns on all the quirks of a server at once, so you can run your client’s tests against “jank” without installing jank:

$ proof serve -listen 127.0.0.1:7888 -like jank

proof in Practice

So far, so good. But does it actually find anything? You bet! Here are some of the things proof turned up in the past couple of months:

  • nREPL itself sent the end of an interrupted eval to the connection the interrupt came from (fixed on master), drops the evals waiting behind an interrupted one (#503) and can leave a session that stops sending done after its client goes away in the middle of an eval (#509). Oops!
  • Babashka, nbb, Basilisp, jank and ClojureCLR all had their share of rough edges, e.g. errors without an eval-error status, sessions sharing *1 and the current namespace, and a request that throws taking its whole connection down.
  • CIDER skipped part of its setup when connecting to a server without cider-nrepl, Calva and Conjure didn’t sort the keys of the dicts they sent, vim-fireplace and REPLy never close their sessions, and Conjure’s interrupts don’t say which eval to stop.

Michiel Borkent fixed the Babashka and nbb issues the same day I reported them (or the next one), Conjure fixed its encoder the next day and Calva within a week. That’s the Clojure community for you!

I was also super happy to see other people picking up proof before I even got around to announcing it. Tom Waddington has been running it against his nREPL implementations for Janet and Steel Scheme since early October (nrepl-janet even tests its client with proof serve), Jeaye Wilkerson suggested using it for jank’s nREPL server, and Kyle Cesare is already fixing what it found there. That’s exactly what I was hoping for!

The projects that benefited from proof the most were my own, though. This year I built a few toy nREPL implementations, mostly for fun and to see how well the protocol holds up outside of Clojure:

  • nrepl-beam has nREPL servers for Erlang (dialtone) and Elixir (repartee). proof found that they didn’t answer close with session-closed, didn’t echo unknown ops and returned only the last value of several forms, and fixing those uncovered a race after close and temporary sessions that never stopped.
  • mezcaml, my little OCaml client, read replies as if only one request was ever in flight, so late output from one eval could end the next one early.
  • neat, my language-agnostic client for Emacs, got late output, errors without err and unknown sessions wrong.
  • nrepl-sci, the nREPL server behind the “Try it” section of nrepl.org, was the first server I wrote with proof around from day one, and working on it mostly came down to running proof after every change.

And then there’s neorepl, the CIDER-inspired terminal REPL I’ve been working on lately, which is just as happy to be used by coding agents as by humans. neorepl and proof pretty much grew up together. I ran neorepl through proof proxy and every scenario of proof serve, which caught a few bugs before neorepl 0.1 was out, and proof checks every server with the exact requests neorepl sends. It’s also how I found the interrupt bugs in nREPL above, which hit neorepl harder than most clients. Its commands are separate processes sharing a session, so neorepl interrupt stops an eval that some neorepl eval started, and that neorepl eval used to wait for its done forever.

A Tool for Tool Authors

I guess I should spell out who proof is for. If you’re using nREPL through CIDER or Calva you’ll probably never run proof yourself (although hopefully you’ll feel its effects). proof is a tool for tool authors - people writing nREPL servers, clients and middleware.

I really hope that it will make it a lot easier to grow the nREPL ecosystem. These days anyone can put together an nREPL server or client in no time with the help of tools like Claude. Writing the code is no longer the hard part - knowing whether the result actually works with CIDER, Calva and friends is. proof gives you (and your coding agent) a fast feedback loop and failures that explain themselves: what went wrong, why it matters and which client code depends on it. That’s usually all an agent needs to fix the problem.

Why “proof”?

Naming is hard! Luckily, the nREPL world has a tradition that makes it a bit easier, as many nREPL clients are named after drinks (CIDER, neat, mezcaml, chaser and so on). proof is not a client, so it bends the rule a bit, but it still belongs at the bar. The proof of a spirit is a measure of its strength, which is more or less what this tool measures for nREPL servers. And running it gives you proof that your server works with the existing clients (or a list of the reasons why it doesn’t). Two meanings for the price of one!2

Epilogue

proof is still in its early days, but it already covers the core of the protocol, the exact requests of the most popular clients and the server quirks clients have to deal with. Now it needs more users.

So, if you’re working on an nREPL server or client, please take proof out for a spin and let me know how it went! I’d love to see your server in the compatibility matrix, and if you think some failure isn’t fair, open an issue and let’s argue about it. And if you’re a casual Clojure hacker who’d just like a nice REPL in the terminal, give neorepl a try:

$ bbin install io.github.nrepl/neorepl

Big thanks to Clojurists Together for supporting my work on nREPL, to Phil Hagelberg for getting the spec going, and to the maintainers of all the servers and clients who were so quick to fix the problems proof found. You rock! In the (n)REPL we trust!

That’s all I have for you today. Keep hacking!

  1. Written in Go, of all things. A Clojure tool would have been the obvious choice, but proof shouldn’t favour any of the languages it checks, and a single binary without a runtime is easy to drop into any CI. ↩

  2. “hangover” would have been a good name for what you get when a server forgets to send done, but it’s a hard sell for a project. ↩

neorepl 0.5: CIDER in a Terminal

neorepl 0.5 is out! When I announced neorepl last month, I wrote that I’d always wanted a terminal REPL that felt like CIDER, without the Emacs part. A few releases later I think neorepl is mostly there, so this one went out with “CIDER in a terminal” for a tagline. It also has everything from the “What’s next” list in the 0.4 post, which almost never happens with my plans.

A line editor that knows Clojure

Editing in neorepl

That’s where you spend most of your time in a REPL, so that’s where most of the work went:

  • The arglists of the function you’re calling show up under the input, the way eldoc shows them in CIDER, with the parameter the argument at the cursor goes into in bold, and the arities that can’t take that many arguments faint. Macros get matched by their shape, so the docstring of a defn lines up with doc-string?. The lookups happen in the background now, so typing never waits on the server.
  • Enter in an unfinished form starts a line indented the way cljfmt would indent it.
  • The delimiter that pairs with the one at the cursor gets highlighted, and a closer that pairs with nothing, or with the wrong kind, shows up in red. JLine’s own idea of paren matching was to move the cursor to the opener for half a second, which always made me think I’d pressed the wrong key.
  • C-x Enter evaluates only the form at the cursor and gives you your input back, which is great for checking a piece of a bigger expression without retyping it. And after an evaluation the prompt shows at its right whether it worked, and how long it took. I borrowed both ideas from clojure-cli.repl, and I’m not sorry at all.
  • C-x C-e opens what you’ve typed in your $EDITOR, as it does in bash, for when a form outgrows the line editor.
  • set editing-mode vi in ~/.inputrc gets you vi’s keys. neorepl takes the readline settings JLine knows from there, and what’s under $if neorepl is for neorepl alone.
  • Commands get highlighted as you type them, and a command that doesn’t exist shows up in red before you press Enter.
Read moreShow less

Not everyone likes that much going on around the cursor, so ,set lists the REPL’s settings and turns them on and off, C-x C-h, C-x C-p and C-x C-q toggle eldoc, pretty printing and quiet mode, and ~/.config/neorepl/config.edn says what they start as:

{:eldoc false
 :print-length 100
 :autoreload true}

Finding your way around

Browsing namespaces and specs

,ns without a name opens a full-screen browser of the project’s namespaces, or every loaded one after you press a. Type / and part of a name to narrow them down, → to see a namespace’s public vars with their docs, Enter to switch to it and e to open it in your editor. ,spec does the same for the spec registry: a spec’s form, the specs it names one key away, and x for an example generated from it, as long as test.check is on the server’s classpath. (With cider-nrepl, x only works since 0.5.1, which came out today as well. I found that bug recording the GIF above.)

,refs lists the functions that use a var, and ,deps the ones a function uses, courtesy of cider-nrepl’s fn-refs and fn-deps. They come in handy before you change a function’s arguments, and even more so for coding agents, which get them as neorepl refs and the MCP refs tool. They only look through the project’s code that’s loaded, mind you.

Reloading and friends

Reloading what changed

,autoreload has the REPL reload the namespaces whose files changed before each evaluation, and before ,test, ,inspect and ,ns, so you’re never calling yesterday’s version of a function. It’s clj-reload under cider-nrepl, which reloads the namespaces that depend on the changed ones too, in the right order, and keeps what defonce defined. When a reload fails you get its error, rather than an evaluation against half-reloaded code.

Coding agents need this even more than people do, as they edit files all day long and forget to load them about as often. neorepl eval --reload and the reload option of the MCP eval tool take care of that.

Taps and profiling

A few more things CIDER users will recognize:

  • Values sent to tap> show up above the prompt, and ,tap opens the last one in the inspector, with cider-nrepl 0.64 or newer.
  • What the server’s own threads print, like a web server’s handlers, shows up above the prompt as well, instead of getting lost in the server’s terminal, with nREPL 1.5 or newer and babashka.
  • ,profile times every call of the functions you name, or of a whole namespace, and without a name shows what it measured: how many calls each function had, and how long they took, on average, in all, at the shortest and the longest. neorepl profile does the same from the shell.

Beside your editor

The CLI was meant for scripts from day one, and it turns out an editor doesn’t need a plugin to use it. neorepl eval -f FILE --line N:COL - evaluates the top-level form at the cursor in the file’s namespace, with the buffer’s text on standard input, so unsaved changes count too, and neorepl eval --ns NS - does the same for a selection. Helix can’t send a buffer to a command, so there the keys save the file first, and that’s all it takes:

[keys.normal."+"]
e = [":write", ":sh neorepl eval -s editor -f %{buffer_name} --line %{cursor_line}:%{cursor_column}"]
l = [":write", ":sh neorepl load %{buffer_name}"]

doc/editors.md has complete setups for Neovim, Emacs and Helix. (Yes, Emacs. Please don’t tell anyone.) The REPL sees whatever the editor evaluates, as they share a server, so with the REPL in a split next to your editor you get a pretty decent setup out of a few lines of config. The setups evaluate in a session of their own, called editor, so ,sessions editor takes the REPL there, and ,pst shows the last error your editor ran into.

More servers, tested for real

neorepl’s tests run against fakes that behave like cider-nrepl, which says nothing about the next cider-nrepl, or the next babashka. So now CI also runs neorepl against the real thing: nREPL with cider-nrepl, started by the Clojure CLI and by Leiningen, babashka’s server, ClojureScript on Node through piggieback and through shadow-cljs, nbb and Basilisp. That turned up a fair share of bugs on both sides of the wire. Running a ClojureScript namespace’s tests through piggieback failed with a circular dependency, a ClojureScript test’s error came with the line NaN, code that threw on nbb didn’t fail neorepl eval, and source, macroexpand and apropos didn’t work on Basilisp. All fixed now.

neorepl needs babashka 1.13.227 or newer these days, as that’s the first release with all the JLine and socket classes it used to work around.

Making the neighbours better, round three

  • I filed a bunch of issues for nbb’s nREPL server, about errors, sessions sharing state and in-ns, and Michiel fixed them within a day or two. They’ll be in the next nbb release. Thanks again, Michiel! On the babashka side, the test output going to the wrong message and info knowing nothing about namespaces are fixed on master.
  • Building on cider-nrepl’s profiler and taps turned up a few rough edges in its ops for them, all filed by now.
  • The nREPL fixes I wrote about last time are merged and waiting for the next nREPL release, and I found a few more ways for an interrupted evaluation to leave a session in a bad state. Interrupts are hard.
  • The piggieback changes are still in review. No news there, I’m afraid.

What’s next

0.6 is about getting neorepl to more people:

  • neorepl on Clojars, which also lets you install it as a Clojure tool and run it inside your project’s JVM, plus an uberjar and Homebrew
  • proper Windows support, at long last
  • a documentation site, as the README is getting rather long
  • the frames of ClojureScript errors, from the JavaScript stack, which have been “next” since 0.2

After that comes 1.0, where the CLI’s exit codes, its edn and JSON output and the MCP tools become a contract I promise to keep.

Try it

$ bbin install io.github.nrepl/neorepl
$ cd my-project
$ neorepl

The CHANGELOG has everything I skipped here, and the README has the details. Please keep the issues and ideas coming!

Keep hacking!

Make the next marketing euro count

Kettle & Pine, our fictional online homeware shop, has EUR 20,000 for its next campaign. The marketing lead wants that budget to bring in customers the business can serve profitably. Search, social and video all have a place in the plan, and their past results give the team a starting point. Where would another euro do the most good?

To answer that, we need to understand how spending produces sales. The channels can help one another: advertising in one place may create demand that another channel converts. So we look at the whole history of spending, promotions, seasonality and sales together, and ask what each channel adds beyond what might have happened anyway. Marketing mix modelling estimates those contributions, which marketers call incrementality.

We use a Bayesian model because several explanations can fit that history. We keep a range of plausible effects and ask how a spending plan performs across them. Marketing and finance can then compare the expected gain with the uncertainty and agree on a plan with the same evidence in view. In our example, an experiment helps them improve the channel mix, and a simulation of fulfilment helps them choose how much demand to acquire.

Read moreShow less

Who already budgets this way

Bayesian marketing mix modelling is no longer a research project. PyMC-Marketing is the open-source Python library behind much of this practice, and we contribute to it. Bolt describes using it for budgeting, for example, and PyMC Labs documents an engagement with Nürnberger Versicherung in which upper-funnel advertising creates demand that paid search later converts. After adopting a model of that funnel, Nürnberger reports a reduction in cost per lead of more than 27% over six months.

Those results belong to those teams. What we’d carry into an engagement of our own is the way the model becomes part of the team’s work. The next budget comes round, someone questions an assumption, and the people making the decision can inspect and adapt the model. We want Kettle & Pine’s example to make that process concrete.

EUR 20,000, four weeks, three channels

We gave the shop 156 weeks of revenue, spending on search, social and video, and the demand, promotions and seasonality that move sales alongside advertising. Making up the business lets us check something we couldn’t check in a real shop: what every other budget would have earned. We know the true advertising effects, so we can compare each recommendation with the plan that would have worked best. The model doesn’t get to see that truth.

The decision comes in week 130, with four campaign weeks ahead. We fit the model to those first 130 weeks and keep the remaining 26 weeks’ revenue hidden, so we can later compare its forecasts with what happened. Before asking it to recommend a budget, we want to know whether it can predict sales it hasn’t seen.

Each channel manager has limits: search must receive EUR 1,000–2,600 per week, social EUR 500–3,000 and video up to EUR 2,500. Today the shop spends EUR 2,000, 1,800 and 1,200 respectively. PyMC-Marketing lets the analyst compare allocations within those limits, with uncertainty about what each change would earn.

Suppose search has earned a good return so far. Does that make it the best place for another euro? Only if it still has room to grow. As an audience saturates, each additional euro buys a little less, so the return on the next euro can differ from the average return on all the money spent before it. That next-euro return is the marginal return on ad spend, or marginal ROAS.

We also need to follow what happens after the spending stops. An ad can keep working for a few weeks, an effect practitioners call adstock or carryover. The model combines carryover and saturation into a response curve for each channel. We use those curves to compare what a budget would earn during the campaign and afterwards.

Two versions of Kettle & Pine's 156-week history. Each shows weekly revenue above weekly ad spend on search, social and video. In the first, social and video flights take turns; in the second, they run in the same weeks. In the final 26 shaded weeks, the model's forecast band follows the revenue that actually happened.
Two versions of Kettle & Pine's history. In the shaded weeks, the dots show the revenue that actually happened and the orange band shows what PyMC-Marketing forecast without seeing it. Foerster Marketing's forecast is practically identical.

Now consider two versions of the shop’s history. Search runs all year in both. Social and video run in flights, as marketers call bursts of a few weeks followed by a pause, but in the first history they take turns and in the second they overlap. Both versions forecast the hidden weeks well: the actual revenue falls inside the 90% band in 23 of 26 weeks when the flights take turns, and in 22 of 26 when they overlap. We can now examine what each model recommends doing with the money.

Start with the history in which the flights take turns. Each channel has weeks of its own, so the model has evidence for telling their effects apart. At current spend, it estimates that the next euro earns more in social and video than in search. The optimiser therefore moves roughly EUR 600 per week out of search and into the other two. Across the campaign and its carryover, that change is worth an expected EUR 1,300 in additional revenue.

PyMC-Marketing’s recommended weekly spending, in EUR:

PlanSearchSocialVideo
Current2,0001,8001,200
Recommended1,4032,0051,592

We’d take the uncertainty to the budget review alongside that gain. The 90% interval runs from about EUR −800 to EUR +3,200, so in some plausible versions of Kettle & Pine the change would do slightly worse. With both the expected EUR 1,300 gain and its downside in view, marketing and finance can decide whether the change is worth making.

Two overlaid histograms of the revenue gain of Foerster Marketing's closely matching plan, one from PyMC-Marketing and one from Foerster Marketing. They almost coincide, peak around EUR 1,500, and 86 percent of the distribution lies above zero.
As a cross-check, each histogram evaluates Foerster Marketing's closely matching plan under one implementation of the model. The two implementations were built independently and agree so closely that their bars blend into one shape.

When two channels always move together

Now switch to the history in which social and video run together. If sales rise after every joint campaign, how much credit should each channel get? One explanation gives most of it to social, another to video, and both can reproduce the sales we saw. That is why forecasting sales well doesn’t settle where to spend the next euro. We need to know what happens when we change one channel without the other.

Statisticians call this an identifiability problem. For the marketing lead, it means the data leave an important budget question open. In this version, the recommendation shifts towards social and keeps video near its current level, while the 90% interval for the gain widens to roughly EUR −1,100 to EUR +4,300. We’d want to understand what supports that shift before acting on it.

Two overlaid histograms of the revenue gain of Foerster Marketing's plan for the overlapping history. They coincide, are wider than in the staggered case, and range from about EUR minus 4,000 to plus 6,000.
The same cross-check of Foerster Marketing's plan for the history in which social and video run together. The plan differs from the one above, and its spread of outcomes is wider.

This gives us a purpose for an experiment. A geo-holdout or similar lift test measures what one channel does when only that channel changes, for instance by pausing it in some regions and comparing them with the rest. The model can then use that evidence to distinguish explanations the spending history couldn’t separate. This is known as calibrating the MMM with lift tests.

In the third version of our study, we add one independent video experiment to the overlapping history. Because its effect also carries over, we give the model the experiment’s whole time path, the weeks before, during and after the treatment. A single before-and-after contrast would leave out part of the process we’re trying to learn from.

Since we generated the data, we can score each recommendation against the plan that would truly have been best. With overlapping flights alone, the model moves money into social and leaves video where it is, and that plan realises only about EUR 1,700 of a possible EUR 3,100 gain. With the lift test, the recommendation moves back to EUR 1,355 / 2,000 / 1,645 for search, social and video, close to what the model chose when the flights took turns, and it realises about EUR 2,800. The experiment improves this campaign’s allocation by roughly EUR 1,100 under the hidden truth. The marketing lead has a reason to change the plan that the overlapping spending history alone could not supply.

Notice that the experiment barely narrows the uncertainty interval, which still runs from about EUR −1,100 to EUR +4,000. If we judged it only by the width of that interval, we’d miss its value. It helps the team put the money in a better place. As a cross-check, Foerster Marketing fitted the calibrated model independently and agrees: its own optimiser lands within about EUR 30 a week of PyMC’s plan on every channel, and both libraries put the expected gain of PyMC’s plan at about EUR 1,560.

Weekly spending and true campaign-plus-carryover gains in EUR, using PyMC-Marketing’s recommendations:

PlanSearchSocialVideoTrue gain
Overlapping flights1,3752,4021,2231,739
Overlapping flights + lift test1,3552,0001,6452,836
Best possible (hidden truth)1,0982,0851,8173,114

Before using an experiment this way in a real engagement, we’d also ask whether its market resembles the rest of the business and whether the benefit justifies the cost of the study. Our example doesn’t price that cost.

A budget for the business you have today

The marketing lead now has an allocation to take to finance. The model has been checked, the experiment gives the team a reason to improve the channel mix, and the uncertainty is there to inspect. Operations can help refine the plan further. Nearly 700 orders are waiting to ship, and the team expects capacity to recover in three weeks. How much demand is worth acquiring while those orders move through fulfilment?

In this extension of our fictional business, a staffing shortage cut fulfilment capacity three weeks ago. Delayed orders create compensation and support costs, and some customers cancel. Those costs affect what the campaign can earn. By including them in the planning, the team can choose a spending level that makes better use of the capacity available now and revisit it as fulfilment recovers.

To answer that, we need an estimate of capacity. Imagine two warehouses that both shipped 550 orders last week. The first had only 550 orders available. The second still has hundreds waiting. Would you draw the same conclusion about how much they could ship next week? In the first warehouse, the report tells us little about spare capacity. In the second, it gives us evidence of a bottleneck. So the backlog changes what the shipment count can tell us.

A shipment count is the end of a process. Orders arrive, the warehouse ships up to its capacity, some waiting orders cancel, and the rest carry into the next week. We use Foerster to run those rules under alternative capacity assumptions and compare the resulting shipments with the reports. Each hypothesis has to explain what happened from the state the warehouse was in when the disruption began.

From three weeks of shipment reports, Foerster estimates reduced capacity at about 550 orders a week, give or take 20, compared with the usual 900. The true figure, which we hid from the inference, is 560. We then call the same warehouse function to simulate the next nine weeks from today’s backlog, pairing draws from the marketing and capacity estimates. It counts orders shipped, delayed and cancelled, and we use margin, delay costs and advertising spend to calculate profit.

Campaign profit at weekly budgets from EUR 0 to 7,500, compared with keeping EUR 5,000 per week. With a clear warehouse, profit peaks at EUR 3,750 and is practically level up to EUR 5,000. With the backlog, it peaks at EUR 2,500, about EUR 4,300 above the EUR 5,000 plan, and falls steeply at higher budgets.
Profit over the four campaign weeks and five weeks of carryover, compared with keeping EUR 5,000 per week. Lines are posterior means and bands are 90% intervals. Blue starts from an empty warehouse at normal capacity, orange from today's backlog of 681 orders and the reduced capacity inferred from the shipment reports.

With an empty warehouse, any budget between EUR 3,750 and 5,000 a week earns about the same, within EUR 250 over the whole campaign. With today’s backlog, EUR 2,500 a week makes the best use of the campaign budget. Compared with keeping EUR 5,000, it earns about EUR 4,300 more, and the 90% interval of that difference, EUR 3,000 to 5,400, stays well above zero. The larger EUR 7,500 plan earns about EUR 6,800 less than keeping EUR 5,000. And since we wrote the warehouse too, we can check against the truth once more: with the hidden capacity and the true advertising effects, EUR 2,500 is indeed the most profitable budget, beating the EUR 5,000 plan by EUR 4,400.

We’ve kept the marketing evidence the same. Bringing fulfilment into the decision helps the team choose how much demand to buy today. Once capacity recovers, the same models would recommend spending more again. Marketing and operations now have a shared reason to bring the next shipment report back to the budget review: it helps them recognise when more advertising becomes worthwhile.

This is the kind of recurring decision we want simulation to help with. Our Stuttgart demonstrator simulated a whole city to ask what would happen to its shops if a shopping centre moved. Kettle & Pine needs a much smaller simulation, but it has a question that comes back every budget cycle. Following demand through fulfilment lets the team connect its campaign investment to the profit it can earn.

Inference that runs where the business runs

For a Python analytics team, PyMC-Marketing is a practical starting point for measurement and allocation. Its budget optimiser already accepts custom constraints and objectives, and an operational model can be built alongside it. We can help the team check whether its records support the allocation it wants to make, fit and validate the model, and bring experiments into the next budget review.

We kept the warehouse example small enough to check independently. It has a backlog, capacity and fixed rules for cancellations and delay costs, and we could have written a separate analytical model of it. We checked the capacity estimate against an independent numerical calculation, and a separate Python implementation reproduced the profit calculation.

What we want to carry further is the connection to the application. Suppose yours already decides which orders get stock first, whether to split a shipment and when to offer compensation. Those rules affect both what the shipment reports tell you and what another campaign would earn. We’d like inference and forecasts to run that code too, because every rule change otherwise gives you two implementations to keep in step.

Foerster is our probabilistic programming library for Clojure on the JVM. Clojure’s immutable, persistent data structures let different versions share unchanged data while keeping their updates separate. That is a useful foundation for exploring several possible futures of an application. Spindel, the runtime beneath Foerster, manages the execution state and its forks.

Foerster’s particle methods can start from the application’s managed state, execute compatible application logic in separate forks under uncertain assumptions, and compare the resulting histories with observations. The same logic can then evaluate proposed plans. In our synthetic warehouse, both steps call the same fulfilment function and leave the caller’s state unchanged. We haven’t integrated the example with a retailer’s production application.

A separate queue demonstration shows why the starting state matters. Hypotheses about service speed run through the application’s scheduling function, and including work already in flight changes which hypothesis the completion reports support. A future run can explore a different queue without changing the parent’s managed state. External actions such as issuing an actual refund still need to be kept out of speculative runs.

Foerster Marketing supplies the demand model in this JVM setting. We implemented Kettle & Pine’s model in both libraries and checked that they agree, with identical data and priors, independently verified densities and gradients, matching posterior estimates and closely matching recommended plans. PyMC still fits this model faster. We’d use Foerster where we want that model to work with the application’s logic and managed state.

Running the application’s code still leaves us modelling choices to check. In this study, we fit demand and capacity separately and treat them as independent, and we supply the cancellation and cost assumptions. At each candidate budget, we choose the channel mix for revenue, then use the warehouse simulation to compare budget levels. We’d need a further step to optimise channel mix and profit jointly. More application detail would earn its place when it changed the decision or exposed a failure in the simpler model.

Bring us a decision

We start with the investment your team wants to make and the decision it keeps coming back to. Then we look at the spending and outcome history, campaign changes, promotions and any lift experiments. Before fitting a model, we ask what those records can tell us and where another experiment would help. The aim is a review your team can repeat, with a documented model, checks against held-out data, feasible plans and the uncertainty about their differences. Agreeing on how to evaluate the recommendation in practice is part of that work.

To connect the campaign to its business outcome, we work with the people who own fulfilment or other relevant processes to identify the state and rules that affect the decision. A first simulation may be small, like Kettle & Pine’s warehouse. Where compatible application logic already exists, we can help it participate directly in inference and plan evaluation, and check the results against observed operations.

If your team is preparing its next budget, get in touch with the decision, the history you have and the constraints that matter. We can help with PyMC-Marketing in Python, or with Foerster where the analysis needs to run inside your business processes on the JVM.

All data in this article are synthetic. The PyMC-Marketing results use version 1.3.0 with PyMC’s default NUTS sampler and four chains of 4,000 draws. The executable study, its validation records and the performance measurements are in the Foerster Marketing repository.

The Pull Newsletter (Nov 5, 2026)

Welcome back to the Pull! Here’s what the Datomic team and community has been up to over the past month.

Datomic Team News

  • Datomic Pro 1.0.7745, featuring several performance improvements and other fixes, is now available.

  • In case you missed it, we’ve launched a new version of datomic.com. Let us know what you think!

  • We also have a brand-new Datomic Query Playground, a read-only interactive environment to try out Datomic queries and read APIs with Clojure. Take it out for a spin and send us any feedback.

  • At the 2026 Clojure Conj, Datomic Developer Success team member João Nascimento Mello led a Datomic Deep Dive workshop. If you weren’t able to attend, you can play along at home with the workshop lab materials.

Community Projects

Speaking of Clojure Conj 2026, speaker Wendy Randolph gave a talk there about "Modeling a Fabric Stash with Datomic," which shows "why Datomic’s attribute-based schema fits sparse, irregular data better than rigid relational tables."

There’s a new video and demo from UnifyBio and the RCRF Pattern Data Commons, which stores molecular and clinical data on rare cancers and is built around Datomic as its primary store and system of record. You can read more about the project on the #datomic channel on Clojurians Slack.

Questions or suggestions? Reach out to the Datomic team and the rest of the community at the #datomic channel on Clojurians Slack.

Lean on the JVM: proved functions for Java and Clojure

Imagine a pricing service written in Java. Customers request a discount, and the business has two firm rules: a discount may never exceed the order subtotal, and it may never exceed the policy cap. Unit tests check a few dozen combinations of amounts, but the rules speak about every order. Sooner or later, a refactoring or a new promotion meets an input nobody tested.

Lean, a programming language and proof assistant, lets you state such a rule as a theorem and have the computer check a proof that it holds for every input. But adopting it usually means leaving the JVM. Lean compiles to native code, and connecting that code to a Java service is a project of its own.

The Ansatz Lean/JVM SDK removes that step. You write the function and its proofs in Lean, prepare the compiled code during your build, and call it from Java or Clojure like any other library. The deployed service needs no Lean installation. We are introducing this workflow with release 0.2.615, and this post follows the pricing rule from proof to production, then shows how Lean code can call back into the application.

Read moreShow less

Write the rule down, then prove it

First, the rule itself. Amounts are whole numbers in the currency’s smallest unit, such as cents. Save this as Pricing.lean:

namespace Pricing

def discount (subtotal requested cap : Nat) : Nat :=
  min subtotal (min requested cap)

theorem discount_le_subtotal (subtotal requested cap : Nat) :
    discount subtotal requested cap ≤ subtotal :=
  Nat.min_le_left _ _

theorem discount_le_cap (subtotal requested cap : Nat) :
    discount subtotal requested cap ≤ cap :=
  Nat.le_trans (Nat.min_le_right _ _) (Nat.min_le_right _ _)

theorem discount_eq_requested {subtotal requested cap : Nat}
    (h₁ : requested ≤ subtotal) (h₂ : requested ≤ cap) :
    discount subtotal requested cap = requested := by
  simp only [discount, Nat.min_eq_left h₂, Nat.min_eq_right h₁]

end Pricing

The definition takes one line: the discount is the smallest of the subtotal, the requested amount and the cap. The theorems are the interesting part. The first says that the discount never exceeds the subtotal, and the second that it never exceeds the cap. But a function that always returns zero would satisfy both bounds, so the third theorem adds that a request within both limits is granted unchanged. Lean checks all three for every possible input when it builds the file.

Of course, a proof is only as good as its statement. Lean guarantees that this definition has these properties; whether they are the right properties remains a business question. The guarantee also covers the Lean code only. The compiler, the JVM runtime and the integration that carry the result into Java are trusted rather than proved. Proofs are erased from the compiled code, so the service never runs a theorem prover on a request.

The example is small on purpose. A team can start with one rule like this, keep its database, HTTP handlers and deployment in Java or Clojure, and move more logic into Lean only where proofs pay off.

Prepare the code once, during the build

Next, add the SDK to an existing Maven project:

<repositories>
  <repository>
    <id>clojars</id>
    <url>https://repo.clojars.org/</url>
  </repository>
</repositories>
<dependencies>
  <dependency>
    <groupId>org.replikativ</groupId>
    <artifactId>ansatz-lean-jvm</artifactId>
    <version>0.2.615</version>
  </dependency>
</dependencies>

It runs on JDK 17 or later and brings Clojure and a JSON library along as ordinary Maven dependencies. It does not need the core Ansatz kernel or a Mathlib store. For the commands below, write the resolved classpath to a file:

mvn dependency:build-classpath -Dmdep.outputFile=target/lean-classpath
export SDK_CLASSPATH="$(cat target/lean-classpath)"

Preparation is the only step that needs native tools. It uses the official Lean 4.33.1 toolchain and a published bundle of helper programs that export what the JVM needs. For now, it runs on Linux x86-64 with prlimit available. Set LEAN_SYSROOT, PRODUCER_BUNDLE and COMPILER_CACHE to the toolchain, the extracted bundle and a cache directory. Then place Pricing.lean in a directory named pricing and prepare the function:

java -cp "$SDK_CLASSPATH" ansatz.lean.jvm.LeanPreparation \
  --source ./pricing \
  --sysroot "$LEAN_SYSROOT" \
  --producer-bundle "$PRODUCER_BUNDLE" \
  --cache "$COMPILER_CACHE" \
  --root-module Pricing \
  --entry Pricing.discount \
  --output ./pricing.leanjvm

The result is a single file, pricing.leanjvm. It contains Lean’s compiled intermediate code, together with the function types, data layouts and initialisation steps that the JVM needs. The command also prints two fingerprints, which are SHA-256 hashes. Keep the manifest hash in your application’s configuration. When the service loads the artifact, it checks the file against that fingerprint, so a changed or swapped file is rejected. One caveat applies to proprietary code: the artifact also contains the Lean source files and compiled modules, so treat it like the source itself. The preparation guide covers the details.

Native tooling belongs to preparation. The deployed artifact runs with the SDK and its ordinary JVM dependencies.

Call it like any other Java method

Then, in the deployed application, load the artifact and ask for the function by name. Save this as PricingApp.java:

import ansatz.lean.jvm.*;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;

public class PricingApp {
    public static void main(String[] args) throws Exception {
        try (Lean runtime = Lean.open();
             var input = Files.newInputStream(Path.of(args[0]));
             LeanProject project = runtime.loadArtifact(input, args[1])) {
            LeanFunction discount = project.function("Pricing.discount");
            BigInteger amount = (BigInteger) discount.invoke(10_000, 1_500, 1_000);
            System.out.println(amount);
        }
    }
}
java --class-path "$SDK_CLASSPATH" PricingApp.java \
  pricing.leanjvm "$EXPECTED_MANIFEST_SHA256"

It prints 1000, because the requested 1,500 is capped at 1,000. Behind the call, the SDK checks the arguments against the Lean signature and converts the result into a BigInteger. The same works for booleans, strings, lists, arrays, pairs, optional values and success-or-error results, and Lean records and recursive datatypes arrive as immutable Java objects.

Clojure developers resolve the same release:

Superficie Clojure What is this syntax?
{:deps {org.replikativ/ansatz-lean-jvm {:mvn/version "0.2.615"}}}
{:deps {org.replikativ/ansatz-lean-jvm {:mvn/version "0.2.615"}}}

and can call the function from the REPL:

Superficie Clojure What is this syntax?
require('[ansatz.lean.jvm :as lean], '[clojure.java.io :as io])

with-open [runtime lean/open(),
           input io/input-stream("pricing.leanjvm"),
           project lean/load-artifact(runtime,
                     input,
                     System/getenv("EXPECTED_MANIFEST_SHA256"))]:
  discount := lean/function(project, "Pricing.discount")
  [discount(10000, 1500, 1000) discount(200, 1500, 1000)]
end
;; => [1000 200]
(require '[ansatz.lean.jvm :as lean]
         '[clojure.java.io :as io])

(with-open [runtime (lean/open)
            input (io/input-stream "pricing.leanjvm")
            project (lean/load-artifact runtime input
                      (System/getenv "EXPECTED_MANIFEST_SHA256"))]
  (let [discount (lean/function project "Pricing.discount")]
    [(discount 10000 1500 1000)
     (discount 200 1500 1000)]))
;; => [1000 200]

The Superficie tab shows the same Clojure forms in another notation. Both run on the same runtime.

Let Lean call the application

So far, information has flowed in one direction, from Java into Lean. Real business logic, however, also needs the application: a configuration value, a database lookup or an exchange rate. This is where running Lean inside the JVM pays off most. If you compile Lean natively instead, you load a platform-specific library through JNI or Panama, start Lean’s runtime and convert every value by hand, as the lean-android-compose demonstration does for an Android app. Calling back into Java then needs C glue code as well. The harder part is memory. Lean frees its objects by reference counting, while the JVM uses a garbage collector, and neither sees the other’s references. A Java object that holds a Lean value must release it when the collector reclaims the Java object, a Lean value that holds a Java object must keep a JNI reference until Lean frees it, and a cycle across the boundary is reclaimed by neither side. With the SDK, both directions use ordinary Java and Clojure values, and the garbage collector manages all of them.

Suppose the cap depends on the customer’s segment and lives in the application’s configuration. Add a declaration without a body to Pricing.lean, together with a function that uses it:

namespace Pricing

@[extern "ansatz_jvm_policy_cap"] opaque policyCap : String → IO Nat

def quote (subtotal requested : Nat) (segment : String) : IO Nat := do
  pure (discount subtotal requested (← policyCap segment))

end Pricing

Prepare it with --entry Pricing.quote --entry Pricing.policyCap. When the application loads the artifact, it supplies the implementation under the declaration’s name:

HostBindings bindings = HostBindings.builder()
    .io("Pricing.policyCap", "config/1", arguments -> {
        Long cap = caps.get((String) arguments.get(0));
        if (cap == null) throw LeanIOError.userError("Unknown segment");
        return BigInteger.valueOf(cap);
    })
    .build();
LeanProject project = runtime.loadArtifact(input, expectedManifestSHA256, bindings);
IOResult<?> result = (IOResult<?>) project.call("Pricing.quote", 10_000, 1_500, "retail");

With caps of 1,000 for retail and 2,500 for partner, the two quotes are 1000 and 1500. An unknown segment reaches Lean as an ordinary IO error and comes back to Java as a LeanException with its message. In Clojure, the binding is a map entry, {"Pricing.policyCap" {:implementation-id "config/1" :effect :io :fn (fn [segment] (get caps segment))}}, passed to lean/load-artifact.

Notice what happens to the proof. discount_le_cap holds for every cap, so it also covers whatever value the configuration returns, and the quote can never exceed the configured cap. A trust boundary appears only when host code replaces a Lean definition, because type checking a binding does not prove that it behaves like the code it replaces. For now, bindings are first-order and synchronous, so they cannot take functions as arguments or run asynchronously. The host-binding guide covers pure bindings, replacements with a Lean fallback and custom result types.

Run it in a service

In production, a few more questions come up. The first is lifetime. Open the Project, as the SDK calls a loaded artifact, once and reuse its function handles. Calls share its code and initialised globals and run one at a time, so use several Projects for parallel work. A new version of the code means loading a new Project; state migration and public execution forks are separate work.

The second is runaway computation. Each call runs under a budget, by default two million interpreter instructions and five seconds. When a workload needs more, raise the budget per call with InvocationOptions.empty().withBudget(instructions, duration) and invokeWith.

The third is failure. Invalid arguments are rejected before anything runs, and an IO function that returns an error reports it as a LeanException. In both cases, the Project stays usable. But when a call fails halfway, for example because it exhausted its budget, the SDK cannot know what state the Lean code left behind. It therefore refuses further calls with ProjectFailed until you call project.restart(). The restart builds a fresh engine from the same verified artifact and keeps your function handles. Module initialisers run again, so globals start over.

Finally, panics behave as in native Lean. panic! and an out-of-bounds xs[i]! print a message to stderr and continue with a default value. If you prefer a hard stop, an option turns a reached panic into a failure instead. The SDK guide covers all four questions.

Bring an existing Lean project

Most teams will not start from a blank file, so we also tried real library code. The insertion-sort module from Functional Programming in Lean uses polymorphic arrays, typeclass comparison, proofs about array size and a termination argument. We load the unchanged module as a dependency of a small Lake project and add one function for the JVM to call:

import Examples.ProgramsProofs.InstrumentedInsertionSort
namespace LakeTutorial

def sortAndOriginal (xs : Array Nat) : IO (Array Nat × Array Nat) :=
  pure (insertionSort xs, xs)

end LakeTutorial

The caller’s Lake configuration requires the pinned fp-lean examples package, which Lake builds first. The SDK’s builtLakeWorkspace() option, or the CLI flag --source-mode built-lake, then takes the built workspace as it is, without fetching or building anything itself. A Java call with List.of(3, 1, 2) returns [[1, 2, 3], [3, 1, 2]] as BigInteger values, and Clojure receives the same pair of vectors. Keeping the original array next to the sorted one also exercises the runtime’s copy-on-write behaviour. Because insertion sort is quadratic, a few hundred elements fit the default budget, while larger inputs need a raised one.

The module proves that sorting preserves the array’s size and terminates, although it does not prove that the result is sorted. It is the work of David Thrane Christiansen and the Functional Programming in Lean contributors. Our corpus pins the revision, and the attribution and CC BY 4.0 source licence document the original work. The Lake guide explains this path, and our native CI also runs selected argument-parsing functions from the unchanged lean4-cli library.

Why the interpreter can fork

Why interpret Lean at all, instead of only calling native code? The answer comes from our own product. Simmis is designed so that people and agents can try a change in a separate branch before anyone accepts it, and that branching should reach running computations as well as stored data. For Clojure, we have already extended SCI, a Clojure interpreter, so that a session can fork into independent histories. Simulations need the same ability. In our Stuttgart city model, a policy question is an intervention evaluated with the same random draws as the baseline, which is easiest when the what-if continues from the very same state.

We are not the first to run Lean’s compiled code on the JVM. Lean4J exports it as JSON and interprets it on GraalVM’s Truffle framework, which compiles hot code to machine code and lets Java, JavaScript and Python pass callbacks in. Its focus is speed and polyglot tooling such as debugging and profiling, it requires GraalVM, and its README states that it is not meant as a faithful drop-in for native Lean. Our SDK makes different trade-offs. It runs on any JDK 17, loads only artifacts that match a trusted fingerprint, takes its Java and Clojure types from Lean’s checked signatures, and refuses unsupported operations before a program starts. It also keeps the whole execution state forkable, which matters most for Simmis. Truffle-style compilation remains a promising route to speed, and we see it as a possible later backend rather than a competing design.

Because the SDK keeps Lean’s entire execution state as JVM objects, it can copy that state. Its engine can already fork a paused computation, at any point in its execution, into two engines that continue independently, and the engine tests check that control state, ownership and output stay separate. The public SDK does not expose forks yet, so this is groundwork rather than a feature of this release.

Internally, we also write most of our verified code in Clojure rather than in Lean’s own syntax. Core Ansatz implements Lean 4’s kernel in Java and offers the same type theory as Clojure forms, so definitions and theorems written in Clojure are checked against the same libraries, such as Mathlib. That lets us add types and proofs to existing Clojure code gradually, one function at a time, and call the results as ordinary Clojure. The SDK described here complements it for code that already exists in Lean. The two can run in the same application, although loading executable code with the SDK does not import its theorems into the Ansatz kernel.

What comes next

This release is a starting point, and two goals guide the next ones. The first is speed. Today the SDK interprets Lean’s compiled code, and the JVM’s just-in-time compiler optimises that interpreter. Our near-term aim is to be competitive with Lean’s own interpreter, lean --run. For workloads that need more, the direction is to compile and specialise Lean code for the JVM, aiming at compiled Lean performance for selected workloads. We have not yet published representative benchmarks. Startup is already cheaper: the SDK’s own Clojure layer ships precompiled, so a fresh JVM pays Clojure’s startup cost but does not compile the SDK.

The second goal is coverage of Lean’s language, standard libraries and runtime services. The current release supports common application types, custom datatypes, explicit IO services and Lean’s Nat, Int and unsigned fixed-width arithmetic. A program that needs an operation outside this profile is refused before it starts, not partway through. Public entry points use closed first-order signatures, and tasks, public forks and Mathlib-heavy projects remain outside this release. A prepared project may contain at most 128 modules besides Lean’s core library, Init. The compatibility ledger records the details.

Lean libraries written partly in C need more work still. Java’s Foreign Function and Memory API, available in JDK 22 and later, offers a route to native libraries. However, Lean’s object representation and ownership rules still need explicit bridges, and this release includes none.

The next useful compatibility target may be your application. Bring us the Lean project and the functions you want to run, together with the JVM environment and workload that matter to you. That tells us which language features, runtime operations and performance requirements to prioritise.

Babashka vs cream

I've given a lot of thought to whether cream should affect babashka. Cream is based on Crema, a native-image compatible bytecode interpreter, basically a JVM inside of native-image.

What it concretely would mean to move babashka to Crema is that we could run virtually any Clojure program without changes. This is exciting.

Crema is an impressive piece of engineering. Native-image could not define classes at run time before, which is the reason SCI exists. Instead of using eval, SCI allows you to interpret Clojure code inside of a native image. With Crema, eval, require and Java libraries just work in a native binary using the normal Clojure compiler (with mild patches). That's the approach that cream takes.

But it has serious downsides compared to using SCI.

Load time

The main downside is the load time of Clojure sources. Evaluating a fn in cream runs the Clojure compiler and emits a class which then has to be loaded by a classloader. This takes longer than it takes SCI to construct an interpreted function.

Read moreShow less

Here is the time to load malli 0.16.4 from source:

DEPS='{:deps {metosin/malli {:mvn/version "0.16.4"}}}'
CP=$(clojure -Spath -Sdeps "$DEPS")
E="(time (require 'malli.core 'malli.generator))"

bb -cp "$CP" -e "$E"
clojure -Sdeps "$DEPS" -M -e "$E"
./cream -Scp "$CP" -M -e "$E"
bb     ## 0.06s
JVM    ############################ 0.93s
cream  ######################################## 1.34s
requirewhole process
bb55ms0.06s
JVM610ms0.93s
cream1310ms1.34s

Babashka on SCI is just way faster for loading bigger libraries from source and then executing some expressions, which is commonly called "scripting". When loading a bigger lib like malli, you might as well just use a normal JVM instead of cream.

The gap shrinks with smaller libraries, but it stays quite significant. Babashka loads medley 9x faster than cream and camel-snake-kebab 15x faster.

Startup is the same in both babashka and cream: about 10ms.

With AOT-compiled classes, malli loads in about 300ms in both cream and the JVM. Cream spends 10ms on startup and 290ms on loading classes, the JVM 200ms on startup and 100ms on loading classes. Run-time performance is worse in cream, so if you AOT anyway, the JVM is the better choice. Cream could AOT-compile libraries on first use and cache the classes, but caches are a hassle, and babashka is faster than the AOT case anyway. So it seems something Crema-based in between bb and a JVM doesn't offer us much benefit here.

Performance

Performance of cream is better than bb for hot JIT-ed code, but worse than a JVM.

bbcreamJVM
malli validate, 1M calls300ms70ms35ms
fib 35, hot1230ms815ms73ms
fib 27, cold26ms46ms5ms
(reduce + (map inc (range 10000000)))157ms725ms80ms

Cream's JIT makes no difference in the last row, it takes 725ms with the JIT off too.

Cream's JIT is still warming up during fib 27. For a script, the code does not run long enough for that JIT to pay off. Since the core feature of bb is scripting with good startup time, bb + SCI wins here.

Binary size and build time

Another major downside of including Crema is the binary size. Even when I only use Crema for loading Java classes, as described below, the binary goes up from 75MB to about 200MB. Cream itself is about 225MB. Maybe this isn't that important, but compile times also go up. I expect double or triple in CI, which isn't fun at all when you build a binary for several OSes. SCI is just way more light weight here too.

Combining SCI and Crema

Another option is to combine both approaches: SCI keeps interpreting Clojure and Crema is only used to load Java classes that are not in the image. I tried this on the crema-hybrid branch and it works for many libraries. A tiny script with BouncyCastle, commons-compress or Caffeine takes about 40ms for the whole process, against about 320ms on the JVM. Loading malli is slow in cream because Clojure compiles every form and defines about 1670 classes. A Java library is already compiled, and a single call only loads the classes it needs.

Load time of Clojure code is not the problem here, since SCI still does that. The main downside of this hybrid is that a JDK package that wasn't preserved at build time aborts the process instead of throwing an exception, and you only find out by running the library. Of course we can protect users from this with extensive testing.

The binary size and build time downsides are the same as for cream. The 200MB applies to every user, even for scripts that never load a class, because Crema needs a table of all compiled methods and whole preserved JDK packages.

If GraalVM shrinks that table or turns the abort into an exception, I'll look at this again.

Babashka + SCI's trade-off

Without Crema, babashka can't load JVM classes that aren't baked into the native image. However, babashka is extensible via pods and more recently FFI. Babashka code is fully portable with JVM Clojure so it should always be easy to migrate a bb scripting project to a full-blown Clojure JVM project, when a project outgrows the light-weight babashka runtime.

Conclusion

This article is not a verdict on Crema, which is still experimental and improving fast. A native binary with full Clojure and a working JIT is very interesting and exciting. Most of the load time comes from the Clojure compiler. It's just not the right trade-off for babashka today. Perhaps with an experimental fork of Clojure that reduces the number of emitted classes, this picture will change. I do use a fork of Clojure for cream, but it only has very mild changes to make it work with Crema. I haven't changed anything significant in the compiler.

The hybrid is more tempting, since it keeps SCI's load time and loads Java libraries faster than a JVM. But today it costs every user about 125MB and slower builds, and a library can still abort the process. So for now it stays on a branch.

For a while I thought SCI would be replaced with Crema in bb, but it still wins for the scripting use case. And babashka is all about scripting. I will keep improving SCI to make it both load and run faster over time. I was planning to do that anyway but the above gives me even more determination to make the babashka + SCI combination the best possible scripting experience for Clojure.

You can view more numbers in the cream README.

kmet - Coding Agent Written in Clojure

2026-11-03 - A coding agent written in Clojure, the language I said couldn't do it - my own TUI stack, a Cordis-style kernel where everything is a plugin, a model that writes Clojure inside a sandbox, and a daily driver at the end instead of a retreat Keywords: clojure, babashka, jolt, coding-agent, agentic-loop, tui, hiccup, cordis, sci

Scittlecastr: The Scittling

Arab woman listening to Scittle podcast with earphones and dancing outdoors

This post was entirely written by a human, with no assistance from LLMs or other "AI" technology such as pervert glasses or autonomous weapon systems.

I've mentioned cljcastr here before, but in a very tongue in cheek fashion. Since then, cljcastr has turned into a serious project to create a set of tools for producing and self-hosting podcasts. The project has two simple principles:

  1. No surveillance. Ever. cljcastr runs on your computer and only your computer. It does not connect to any external services, store data on the cloud (AKA other people's computers), or track you in any way, shape, or form.
  2. No AI. All cljcastr code is written by hand, by humans. Nothing in this repository has been AI generated, and no AI generated pull requests will even be considered for inclusion.

cljcastr is hosted on Codeberg at jmglov/cljcastr.

In this blog post, I'm going to demonstrate the cljcastr ethos by implementing a simple podcast player in ClojureScript, using the mighty Scittle. This is gonna be a long one (even by my standards), so here's a table of contents to help us all stay oriented.

Read moreShow less

Table of contents

  1. Building the skeleton - setting up a Babashka project and a Scittle app and connecting a REPL to it
  2. Interactive development - getting used to the REPL and logging Clojure data structures in the browser console
  3. Getting down to brass HTML - adding a heading
  4. Play that funky music - adding an audio player
  5. Escaping from vanilla - adding custom controls
  6. Rainbows and unicorns - applying styles
  7. Play it again, Sam - adding event listeners
  8. A11y spells ally - making controls accessible
  9. Sweeping up the shop - adding DOM helper functions and refactoring
  10. Sweeping audio under the carpet - hiding the standard audio player
  11. Hours, minutes, and seconds, oh my! - displaying elapsed and remaining time in a human-readable form
  12. Laying out the law - adding episode info in a nice layout
  13. Slipping and sliding - adding a progress bar and draggable position slider
  14. Getting into position - combining the progress bar and position slider into a typical scrubbing control

Building the skeleton

To get started, we're going to need a Babashka project set up to serve files from a local webserver. Let's create a cljcastr directory:

mkdir cljcastr

And now pop a bb.edn into it:

{:deps {io.github.babashka/sci.nrepl
        {:git/sha "80ec956af5e6610b040089ba62eecb5d223e9e76"}
        io.github.babashka/http-server
        {:git/sha "b38c1f16ad2c618adae2c3b102a5520c261a7dd3"}}

 :tasks
 {http-server {:doc "Starts http server for serving static files"
               :requires ([babashka.http-server :as http])
               :task (do (http/serve {:port 1341 :dir "resources/public"})
                         (println "Serving static assets at http://localhost:1341"))}

  browser-nrepl {:doc "Start browser nREPL"
                 :requires ([sci.nrepl.browser-server :as bp])
                 :task (bp/start! {})}

  -dev {:depends [browser-nrepl http-server]}

  dev {:doc "Development build. Starts webserver and watches for changes."
       :task (do (run '-dev {:parallel true})
                 (deref (promise)))}}
}

Now create a resources/public directory:

mkdir -p resources/public/

Drop an index.html in that directory:

<html xmlns="http://www.w3.org/1999/xhtml">

<head>
  <title>cljcastr episode player</title>

  <script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js" type="application/javascript"></script>
  <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>
  <script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js" type="application/javascript"></script>

  <script type="application/x-scittle" src="player.cljs"></script>
</head>

<body>
</body>

</html>

And finally, a player.cljs in the same directory that does naught but declare a namespace:

(ns cljcastr.player)

(js/console.log "Ready to play!")

Thus equipped, invoke Babashka from the top-level cljcastr directory thusly:

bb dev

Babashka will helpfully tell you where you can find your player:

Serving static assets at http://localhost:1341
nREPL server started on port 1339...
Websocket server started on 1340...

If you browse to localhost:1341, you'll see an amazingly blank page titled "cljcastr episode player". If you open the JavaScript console in dev tools (Ctrl + Shift + i in many browsers), you should see the player reporting for duty:

  Ready to play!                                                    core.cljs:80
> 

So let's play!

Interactive development

In addition to the HTTP server serving up static files from our resources/public directory, we have an nREPL server listening on port 1339 and a websocket server on port 1340. When we loaded the index.html webpage in the browser, scittle.nrepl opened a websocket connection to port 1340 and started listening for commands. In order to send it some commands, we will need to connect an nREPL client to the nREPL server on port 1339. Let's do it!

This section assumes that you are a reasonable person and are thus using Emacs with CIDER, but if you've unreasonably chosen to use a lesser editor or one of those new-fangled "IDEs" like Cursive or Calva, you probably know how to operate nREPL in that editor, so just translate accordingly.

First, we open up player.cljs in our editor. Next, we use CIDER's cider-connect-cljs command (C-c M-C) to connect to localhost on port 1339, choosing REPL type nbb when prompted. CIDER should open a REPL buffer that says something like this:

;; Connected to nREPL server - nrepl://localhost:1339
;; CIDER 1.12.0 (Split)
;;
;; ClojureScript REPL type: nbb
;;
nil> 

Now we're all set up for interactive development, so let's interact. First, execute cider-load-buffer (C-c C-k) in the player.cljs buffer. If you're watching the JS console in your browser, you'll see something like the following pop up:

  Ready to play!                                                   nrepl.cljs:10
> 

And meanwhile in your terminal, you'll see the nREPL server log some output:

:msg "{:value \"nil\", :id \"246\", :session \"e443955d-e285-4d5b-bbde-9b7f21f1561c\", :ns \"cljcastr.player\"}"
:msg "{:status [\"done\"], :id \"246\", :session \"e443955d-e285-4d5b-bbde-9b7f21f1561c\", :ns \"cljcastr.player\"}"

We've now completed the circle of LISP, meaning we can control everything on our player page with ClojureScript! 💥

Let's try some simple arithmetic just to get a sense of the power at our fingertips. In the player.cljs buffer, we'll add a Rich comment and some code:

(comment

  (+ 1 2)

  )

Positioning our cursor right after the (+ 1 2) form, we hit C-c C-v f c e to execute cider-pprint-eval-last-sexp-to-comment, and are rewarded by a nice comment being inserted on the next line:

(comment

  (+ 1 2)
  ;; => 3

  )

We can also print this result to the browser console by adding another line inside the (comment) form:

(comment

  ;; ...
  
  (js/console.log (+ 1 2))

  )

If you're using CIDER with an autocomplete setup, you should actually see completion candidates popping up as you type. 🤯

If you position the cursor after the form and evaluate it again with C-c C-v f c e, you'll get a comment on the next line saying

  ;; => nil

This is somewhat underwhelming, but if you take a peek at your browser console, you'll be more satisfied:

  3                                                                nrepl.cljs:10
> 

Can we have our cake and eat it too? (Note that from here on, I'll assume you're whacking C-c C-v f c e after the new form you add, which will produce the ;; => comment on the next line.)

(comment

  ;; ...
  
  (doto (+ 1 2)
    js/console.log)
  ;; => 3

  )

Now we're cooking with biofuel!

Let's try something a little more complex:

(comment

  ;; ...
  
  (doto (map inc (range 5))
    js/console.log)
  ;; => (1 2 3 4 5)

  )

This is great, but what in the world is happening over in the browser console?

  ‣ $APP.gg {J: null, na: null, I: null, F: 32374988, ma: ƒ, …}    nrepl.cljs:10
> 

If we click on the ‣, things get even wilder:

  ‣ $APP.gg {J: null, na: null, I: null, F: 32374988, ma: ƒ, …}    nrepl.cljs:10
      F: 32374988
      I: null
      J: null
      M: 1
      ma: null
    ‣ na: $APP.lg {hc: kg, Zc: $…P.gg, J: null, I: null, F: 31850732, …}
    ‣ [[Prototype]]: Object
>

What we're seeing is a JavaScript representation of a ClojureScript vector. This is of course super cool and all, but not so readable (unless you're Rich Hickey and you don't even see the persistent data structures but just the woman in the red dress). Let's see if we can do better:

(comment

  ;; ...
  
  (doto (->> (map inc (range 5))
             clj->js)
    js/console.log)
  ;; => #js [1 2 3 4 5]

  )

The browser console is looking good!

  ‣ (5) [1, 2, 3, 4, 5]                                            nrepl.cljs:10

But life inside our (comment) form is less idyllic. What's up with this nonsense?

  ;; => #js [1 2 3 4 5]

Now we're seeing ClojureScript's JavaScript data literal. 😅

Let's try one more thing:

(comment

  ;; ...
  
  (let [res (map inc (range 5))]
    (js/console.log (clj->js res))
    res)
  ;; => (1 2 3 4 5)

  )

That's much better! Now how about we extract this to a function so we can use it to log stuff?

(ns cljcastr.player)

(defn log [v]
  (js/console.log (clj->js v))
  v)

(comment

  ;; ...

  (log (map inc (range 5)))
  ;; => (1 2 3 4 5)

  )

Good stuff. Thus equipped, we can start sprucing up this empty webpage.

Getting down to brass HTML

Let's add an h1 HTML element to the page to let users know what's up. The obvious way to do this would be to open up index.html and drop in something like this:

<body>
  <h1>cljcastr player</h1>
</body>

However, this would require a page reload for us to see the changes, and that would blow away the state we've carefully built up in our REPL. 😭

Let's see if we can do this in the REPL instead. The basic idea is this:

  1. Create an h1 element.
  2. Set its inner text to "cljcastr player".
  3. Grab the body element that we helpfully put in our HTML page skeleton.
  4. Add the new h1 element as a child of the body element.

And if we were to call an imaginary eng->clj function, that would look something like this:

(comment

  ;; ...
  
  ;; 1. Create an **h1** element.
  (def h1 (js/document.createElement "h1"))
  ;; => #'cljcastr.player/h1

  ;; 2. Set its inner text to "cljcastr player".
  (set! (.-textContent h1) "cljcastr player")
  ;; => "cljcastr player"

  ;; 3. Grab the **body** element that we helpfully put in our HTML page skeleton.
  (def body (js/document.querySelector "body"))
  ;; => #'cljcastr.player/body

  ;; 4. Add the new **h1** element as a child of the **body** element.
  (.appendChild body h1)
  ;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]

  )

Et voilà! Our boring page has been transformed into an amazing feast for the eyes!

Before moving on, let's take a brief detour into a new JavaScript literal:

  ;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]

This is a bit opaque, but we can always feed it to our log function to make it nicely inspectable in the console:

(comment

  ;; ...
  
  (log h1)
  ;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]

  )

The console will render this as:

  <h1>cljcastr player</h1>                                         nrepl.cljs:10

Play that funky music

Let's find ourselves the URL of a random podcast episode and add an audio element to the page:

(comment

  ;; ...

  (def audio (js/document.createElement "audio"))
  ;; => #'cljcastr.player/audio

  (set! (.-controls audio) true)
  ;; => true

  (set! (.-src audio)
        "https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3")

  (->> audio
       (.appendChild body))
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  (log audio)
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

)

Now we should see an audio player pop up on screen!

Simple podcast player with a heading and an audio player

And if we click play, we hear the defn theme music.

We could declare victory right here, but we're missing a few things that a podcast player usually has, such as:

  1. Sweet custom controls instead of the ugly HTML5 default audio player.
  2. The ability to play any podcast episode, not just one we've helpfully downloaded ahead of time.
  3. If a transcript is available, display it with clickable timestamps.

Let's see what we can do about that.

Escaping from vanilla

The standard HTML audio control is functional, but a bit boring. In our hubris, we decide we can do way better than that!

Let's look at a typical podcast player for inspiration:

Overcast podcast player

It looks like we'll want the following buttons:

  • Play
  • Pause
  • Skip backward
  • Skip forward
  • Playback speed

Let's see if we can find some nice vector graphics for our buttons. SVG Repo is a great place to start, and a search there for "play" turns up a nice looking SVG named Play Circle Fill SVG, which is provided under the MIT Licence. That looks promising!

Better yet, the SVG is part of a collection called [Framework7 Filled Icons] (https://www.svgrepo.com/collection/framework7-filled-icons/). Let's click into that and see if we can find some SVGs for other useful media player buttons. Searching within the collection, we find a nice Pause button, but nothing for the other three we need. 😭

No matter! We widen our search to all of SVG Repo and find nice graphics for the rest. Here's where we land:

Let's create a set of HTML images for each. We'll start by defining a map to describe them:

(comment

  ;; ...

  (def playback-controls
    {:play {:src "https://www.svgrepo.com/show/436852/play-circle-fill.svg"
            :label "Play"}
     :pause {:src "https://www.svgrepo.com/show/436829/pause-circle-fill.svg"
             :label "Pause"}
     :forward {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
               :label "Forward {{seek-dur-sec}} seconds"}
     :backward {:src "https://www.svgrepo.com/show/495036/backward-15-seconds.svg"
             :label "Back {{seek-dur-sec}} seconds"}
     :speed {:src "https://www.svgrepo.com/show/151785/speed-meter.svg"
             :label "Playback speed"}})
  ;; => #'cljcastr.player/playback-controls

  (def seek-dur-sec 15)
  ;; => #'cljcastr.player/seek-dur-sec

  )

Now we can map over the map (yeah yeah, I know) to create our img elements:

(comment

  ;; ...

  (def playback-buttons
    (->> playback-controls
         (map (fn [[control-name {:keys [src label]}]]
                (let [el (js/document.createElement "img")]
                  (set! (.-id el) (str "button-" (name control-name)))
                  (set! (.-classList el) "cljcastr playback-icon")
                  (set! (.-src el) src)
                  (set! (.-draggable el) false)
                  el)))
         log))
  ;; => #'cljcastr.player/playback-buttons

  )

Note that we're adding a playback-icon class to the element in addition to our standard cljcastr class, and also giving the element an ID so it's easy to grab it with a CSS selector later. We also set the draggable attribute to false to prevent drag-and-drop silliness from happening.

Now, how about a container to hold them?

(comment

  ;; ...

  (def playback-controls-container
    (-> (let [el (js/document.createElement "div")]
          (set! (.-id el) "playback-controls")
          (set! (.-classList el) "cljcastr")
          el)
        log))
  ;; => #'cljcastr.player/playback-controls-container

  (doseq [el playback-buttons]
    (.appendChild playback-controls-container el))
  ;; => nil

  )

Finally, let's display the controls!

(comment

  ;; ...

  (.appendChild body playback-controls-container)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Player with a huge play button

Let's set the width of the images to a more reasonable size:

(comment

  ;; ...

  (doseq [el playback-buttons]
    (set! (.-style el) "width: 35px"))
  ;; => nil

  )

That's much better! However, this black and white page is starting to bum us out.

Rainbows and unicorns

Since our controls are SVGs, we can use the magic of CSS filters to colour them.

Let's start by picking a colour, and what better colour for the sake of this demo than a lovely amber sort of shade called #f7931e. Of course, CSS filters can't be specified in something as prosaic as hex colour codes, so we'll use a magic CodePen thingy to generate a filter from our hex code. (We could also port the JavaScript from this spectacular StackOverflow answer to ClojureScript, but we kinda don't feel like it.)

What we come up with is this:

filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);

We can now add this as a style to our img tags:

(comment

  ;; ...

  (doseq [el playback-buttons]
    (set! (.-style el)
          (str "width: 35px; filter: invert(59%) sepia(92%) saturate(611%) "
               "hue-rotate(343deg) brightness(97%) contrast(99%);")))
  ;; => nil

  )

Better!

Player with a orange playback controls

One thing that's slightly annoying is that we had to remember to include to include the width style when we added the filters. Why don't we write some code that adds a style to an existing element whilst preserving existing styles?

First, let's help ourselves out by pulling in clojure.string:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...

(comment

  ;; ...

  ;; What do styles look like?
  (-> playback-buttons
      first
      (.getAttribute "style"))
  ;; => "width: 35px; filter: invert(59%) sepia(92%) saturate(611%)
  ;;     hue-rotate(343deg) brightness(97%) contrast(99%);"

  ;; Parse into a list of styles
  (-> playback-buttons
      first
      (.getAttribute "style")
      (str/split #";\s+"))
  ;; => ["width: 35px"
  ;;     "filter: invert(59%) sepia(92%) saturate(611%)
  ;;      hue-rotate(343deg) brightness(97%) contrast(99%);"]

  ;; Turn it into a map
  (let [styles (-> playback-buttons
                   first
                   (.getAttribute "style")
                   (str/split #";\s+"))]
    (->> styles
         (map #(let [[k v] (str/split % #":\s+")]
                 [(keyword k) v]))
         (into {})))
  ;; => {:width "35px",
  ;;     :filter "invert(59%) sepia(92%) saturate(611%)
  ;;              hue-rotate(343deg) brightness(97%) contrast(99%);"}

  )

Now we have a thing that we can easily turn into a function!

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; (defn log ...)

(defn get-styles
  "Returns the inline styles of an element as a map of style name to value."
  [el]
  (let [styles (-> el
                   (.getAttribute "style")
                   (str/split #";\s+"))]
    (->> styles
         (map #(let [[k v] (str/split % #":\s+")]
                 [(-> (str/trim k) keyword) (str/trim v)]))
         (into {}))))

;; ...

(comment

  ;; ...

  (-> playback-buttons
      first
      get-styles)
  ;; => {:width "35px",
  ;;     :filter "invert(59%) sepia(92%) saturate(611%)
  ;;              hue-rotate(343deg) brightness(97%) contrast(99%);"}

  )

How about going the other direction, i.e. turning a map of styles into a style string?

(comment

  ;; ...

  (->> {:width "35px", :height "35px"}
       (map (fn [[k v]]
              (str (name k) ": " v)))
       (str/join "; "))
  ;; => "width: 35px; height: 35px"

  )

From this, 'tis merely a hop, skip, and a jump to write a function that sets the styles of an element:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; (defn get-styles ...)

(defn set-styles!
  "Sets the styles of an element from a map of style name to value."
  [el styles]
  (let [styles-str (->> styles
                        (map (fn [[k v]]
                               (str (name k) ": " v)))
                        (str/join "; "))]
    (set! (.-style el) styles-str))
  el)

;; ...

(comment

  ;; ...

  ;; Back to black!
  (doseq [el playback-buttons]
    (set-styles! el {:width "35px"}))
  ;; => nil

  )

Finally, let's put this together and create a function that adds a style instead of replacing all the existing ones:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn set-styles! ...)

(defn add-style!
  "Adds a style to an element"
  [el style-name style-value]
  (let [styles (get-styles el)]
    (set-styles! el (assoc styles (name style-name) style-value)))
  el)

;; ...

(comment

  ;; ...

  ;; Amber is the new black
  (doseq [el playback-buttons]
    (add-style! el :filter
                (str "invert(59%) sepia(92%) saturate(611%) "
                     "hue-rotate(343deg) brightness(97%) contrast(99%)")))
  ;; => nil

  )

Play it again, Sam

Now that we have visually stunning buttons, let's actually make them do something.

Or rather, let's do something that allows us to make them do something. 😅

Remember how we added a cljcastr class to the buttons so we could easier grab them with js/document.querySelector? Well, we didn't do this to our poor audio element, which will now cause us a problem:

(comment

  ;; ...

  (-> (js/document.querySelector "audio")
      log)
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  )

Seems OK in the REPL, but if we look at the JS console, things (might not, depending on your browser and which extensions you have installed) look so good:

  <audio class="audio-for-speech" src></audio>                     nrepl.cljs:10

Seems like my browser is set up with a screen reader, which is very nice for accessibility, but not nice for Clojure programmers who didn't get enough hammock time. Let's add a class so we can use a more precise selector:

(comment

  ;; ...

  (set! (.-classList audio) "cljcastr")
  ;; => "cljcastr"

  (-> (js/document.querySelector "audio.cljcastr")
      log)
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  )

And now the console looks more like what we expected:

  <audio class="cljcastr" controls                                 nrepl.cljs:10
  src="https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3">
  </audio>

Now we can proceed with making our buttons come alive:

(comment

  ;; ...

  ;; Grab the play button
  (def play-button (js/document.querySelector "#button-play"))
  ;; => #'cljcastr.player/play-button

  ;; Add a click handler
  (.addEventListener play-button "click"
                     (fn [ev]
                       (log ev)
                       (.play (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  )

If we click the button, we hear the podcast start playing, and can see the playback position in the standard HTML audio element above.

Player playing an episode

Now we can do the same for the pause button:

(comment

  ;; ...

  ;; Grab the pause button
  (def pause-button (js/document.querySelector "#button-pause"))
  ;; => #'cljcastr.player/play-button

  ;; Add a click handler
  (.addEventListener pause-button "click"
                     (fn [ev]
                       (log ev)
                       (.pause (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  )

That was easy! Skipping forward will be a little more interesting, since there's no JS function to tell the audio object "skip forward x seconds", there's only a currentTime property that we can query and set:

(comment

  ;; ...

  (.-currentTime audio)
  ;; => 29.396358

  (set! (.-currentTime audio) 15.0)
  ;; => 15

  )

Given this, it's not too hard to figure out how to skip forward and backward an arbitrary number of seconds:

(comment

  ;; ...

  ;; Forward
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (+ cur seek-dur-sec)))
  ;; => 30

  ;; Backward
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (- cur seek-dur-sec)))
  ;; => 15

  )

It's probably a good idea not to skip forward past the end of the episode or backward past the beginning, so let's see if we can put a stop to that silliness:

(comment

  ;; ...

  ;; Forward with guard
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (min (+ cur seek-dur-sec)
                                     (.-duration audio))))
  ;; => 30

  ;; The end is nigh!
  (set! (.-currentTime audio) (- (.-duration audio) 5))
  ;; => 5365.949958

  ;; Don't worry, all is well
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (min (+ cur seek-dur-sec)
                                     (.-duration audio))))
  ;; => 5370.949958

  ;; Hitting 88 miles per hour
  (set! (.-currentTime audio) 5)
  ;; => 5

  ;; Backward
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (max (- cur seek-dur-sec)
                                     0)))
  ;; => 0

  )

Armed with this code, let's make some functions:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-style! ...)

(defn skip-forward! [audio-el sec]
  (let [cur (.-currentTime audio-el)]
    (set! (.-currentTime audio-el) (min (+ cur sec)
                                        (.-duration audio-el)))))

(defn skip-backward! [audio-el sec]
  (let [cur (.-currentTime audio-el)]
    (set! (.-currentTime audio-el) (max (- cur sec)
                                        0))))

;; ...

(comment

  ;; ...

  (skip-forward! audio seek-dur-sec)
  ;; => 15

  (skip-backward! audio seek-dur-sec)
  ;; => 0

  )

Now we can wire up the skip buttons as well:

(comment

  ;; ...

  (def forward-button (js/document.querySelector "#button-forward"))
  ;; => #'cljcastr.player/forward-button

  (def backward-button (js/document.querySelector "#button-backward"))
  ;; => #'cljcastr.player/backward-button

  (.addEventListener forward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-forward! audio seek-dur-sec)))
  ;; => nil

  (.addEventListener backward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-backward! audio seek-dur-sec)))
  ;; => nil

  )

Look at this! All of our buttons work! (Except the playback speed one, but that's a little more complicated, so let's save it for later. 😉)

The only thing that's obviously wrong with this amazing player is that it has two sets of controls, the standard HTML one and our amazing buttons. There's also something that's not obviously wrong to most readers, unless they happen to be using a screen reader: the accessibility of our buttons sucks! In fact, the only way we know they're buttons is by their appearance, because we're using img elements, which are semantically just images. They could be images of an elephant for all a screen reader knows.

Let's see what we can do about that!

A11y spells ally

From the excellent MDN article on Accessibility:

Accessibility (often abbreviated to A11y — as in, "a", then 11 characters, and then "y") in web development means enabling as many people as possible to use websites, even when those people's abilities are limited in some way.

For many people, technology makes things easier. For people with disabilities, technology makes things possible. Accessibility means developing content to be as accessible as possible, no matter an individual's physical and cognitive abilities and how they access the web.

The W3C Accessibility Initiative puts it in even more inspiring terms:

The Web is fundamentally designed to work for all people, whatever their hardware, software, language, location, or ability. When the Web meets this goal, it is accessible to people with a diverse range of hearing, movement, sight, and cognitive ability.

We certainly want all people to adopt our superior podcast player, so let's make sure it works for them, using the power of ARIA!

Arya Stark after killing the Frey soldier with a knife

No, not that one. 😉

This one: ARIA.

If we wrap each of our img elements in a button element, we're now conveying semantic meaning to a screen reader (or any other user agent, for that matter; imagine a script that is trying to identify all the buttons on a page... such as the one we're in the process of writing). And if we set that button's aria-label attribute, we're now identifying what the button is meant to do.

Remember the :label key we included in our playback-controls map but never used way back when?

(comment

  ;; ...

  (def playback-controls
    {:play {:src "https://www.svgrepo.com/show/436852/play-circle-fill.svg"
            :label "Play"}
     :pause {:src "https://www.svgrepo.com/show/436829/pause-circle-fill.svg"
             :label "Pause"}
     :forward {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
               :label "Forward {{seek-dur-sec}} seconds"}
     :backward {:src "https://www.svgrepo.com/show/495036/backward-15-seconds.svg"
             :label "Back {{seek-dur-sec}} seconds"}
     :speed {:src "https://www.svgrepo.com/show/151785/speed-meter.svg"
             :label "Playback speed"}})
  ;; => #'cljcastr.player/playback-controls

  )

Turns out we had accessibility in mind all along!

Let's try wrapping a button:

(comment

  ;; ...

  (let [button-name :play
        {:keys [src label]} {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
                             :label "Forward {{seek-dur-sec}} seconds"}]
    (let [button-id (str "button-" (name button-name))
          button-el (js/document.createElement "button")
          img-el (js/document.createElement "img")]
      (set! (.-id button-el) button-id)
      (set! (.-classList button-el) "cljcastr")
      (set! (.-ariaLabel button-el) label)
      (set! (.-id img-el) (str button-id "-icon"))
      (set! (.-classList img-el) "cljcastr playback-icon")
      (set! (.-src img-el) src)
      (set! (.-draggable img-el) false)
      (.appendChild button-el img-el)
      (log button-el)))
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

All of this set! stuff is starting to get annoying. Why don't we write a nice function that we can call to give us back a fully baked DOM element?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-style! ...)

(defn create-el [{:keys [tag attrs] :as args}]
  (let [el (js/document.createElement tag)]
    (doseq [[attr v] attrs]
      (.setAttribute el (name attr) v))
    el))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (let [button-name :play
        {:keys [src label]} {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
                             :label "Forward {{seek-dur-sec}} seconds"}]
    (let [button-id (str "button-" (name button-name))
          button-el (create-el {:tag "button"
                                :attrs {:id button-id
                                        :class "cljcastr"
                                        :aria-label label}})
          img-el (create-el {:tag "img"
                             :attrs {:id (str button-id "-icon")
                                     :class "cljcastr playback-icon"
                                     :src src
                                     :draggable false}})]
      (.appendChild button-el img-el)
      (log button-el)))
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

This looks pretty reasonable in the JS console:

  ‣ <button class="cljcastr" id="button-play"                      nrepl.cljs:10
            aria-label="Forward {{seek-dur-sec}} seconds">
      <img class="cljcastr playback-icon" id="button-play-icon"
           src="https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
           draggable="false">
    </button>

Except this bit, that is:

aria-label="Forward {{seek-dur-sec}} seconds"

Looks like we got a bit clever there with some Selmer-style templating to allow for changing the number of seconds we're skipping around. Let's quickly figure out how to interpolate such strings:

(comment

  ;; ...

  (let [s "Something {{something}} something"]
    (str/replace s "{{something}}" "SOMETHING"))
  ;; => "Something SOMETHING something"

  (let [s "Something {{something}} something"
        ctx {:something "SOMETHING"}]
    (str/replace s #"\{\{(something)\}\}" (fn [[_ k]] (get ctx (keyword k)))))
  ;; => "Something SOMETHING something"

  )

This may appear slightly inscrutable at first glance, but if we look at the documentation for clojure.string/replace, we see that the replacement argument can actually be a function, in which case it's passed the match as an argument and whatever it returns is the replacement string. When the match contains a capturing group, the argument to the function is a vector of [match group1 group2 ...]. For example:

(comment

  ;; ...

  (let [s "It's easy as 123, ABC, baby you and me"]
    (str/replace s #"(\d)+.+([A-Z]+)"
                 (fn [match-vector] (str "<<<" (pr-str match-vector) ">>>"))))
  ;; => "It's easy as <<<[\"123, ABC\" \"3\" \"C\"]>>>, baby you and me"

  )

Let's turn this into a proper function:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; (defn log ...)

(defn render
  "Given a string `s` and a context map `ctx`, replaces template variables of the
   form {{k}} with the value of the corresponding key in the context map. If
   the key `k` doesn't exist in the context map, the template variable won't be
   replaced (i.e. \"{{foo}}\" will remain \"{{foo}}\" in the interpolated
   string)."
  [s ctx]
  (str/replace s #"\{\{([^}]+)\}\}"
               (fn [[match k]] (get ctx (keyword k) match))))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (render "foo {{bar}} baz {{blah}} {{barf}}" {:bar 27, :blah 42})
  ;; => "foo 27 baz 42 {{barf}}"

  )

Now we can make use of our new render function in our button-wrapping code:

(comment

  ;; ...

  (let [button-name :play
        {:keys [src label]} {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
                             :label "Forward {{seek-dur-sec}} seconds"}]
    (let [button-id (str "button-" (name button-name))
          button-el (create-el {:tag "button"
                                :attrs {:id button-id
                                        :class "cljcastr"
                                        :aria-label (render label {:seek-dur-sec seek-dur-sec})}})
          img-el (create-el {:tag "img"
                             :attrs {:id (str button-id "-icon")
                                     :class "cljcastr playback-icon"
                                     :src src
                                     :draggable false}})]
      (.appendChild button-el img-el)
      (log button-el)))
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

Quoth the console:

  ‣ <button class="cljcastr" id="button-forward"                      nrepl.cljs:10
            aria-label="Forward 15 seconds">
      <img class="cljcastr playback-icon" id="button-forward-icon"
           src="https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
           draggable="false">
    </button>

Now we can extract a nice function to create a button:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn create-el ...)

(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src click-handler]} ctx]
   (let [button-id (str "button-" (name button-name))
         button-el (create-el {:tag "button"
                               :attrs {:id button-id
                                       :class (render "cljcastr {{button-class}}" ctx)
                                       :aria-label (render label ctx)}})
         img-el (create-el {:tag "img"
                            :attrs {:id (str button-id "-icon")
                                    :class (render "cljcastr {{img-class}}" ctx)
                                    :src src
                                    :draggable false}})]
     (.addEventListener button-el "click" click-handler)
     (.appendChild button-el img-el)
     button-el)))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (->> (create-button :forward
                      {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
                       :label "Forward {{seek-dur-sec}} seconds"}
                      {:seek-dur-sec seek-dur-sec
                       :button-class "playback-button"
                       :img-class "playback-icon"})
       log)
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

We've pulled another nice thing out of Clojure's bag of tricks: multi-arity functions:

(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src]} ctx]
   ;; ...
  ))

This allows us to call create-button without a context:

(create-button :play {:label "Play", :src "play.svg"})

This will call the version of create-button that takes two arguments, which in turn calls the three-argument version, passing along the button-name and button arguments as is, and adding an empty map as the ctx argument.

OK, now we can use our create-button function to sew new a11y'd buttons onto our playback-controls div:

(comment

  ;; ...

  ;; Throw away the current crappy buttons
  (.replaceChildren playback-controls-container)
  ;; => nil

  ;; Let's fix the order of our buttons whilst we're at it
  (def playback-buttons
    (->> [:backward :play :pause :forward :speed]
         (map (fn [button-name]
                [button-name (get playback-controls button-name)]))
         (map (fn [[button-name button]]
                (let [button-el (create-button button-name button
                                               {:seek-dur-sec seek-dur-sec
                                                :button-class "playback-button"
                                                :img-class "playback-icon"})]
                  (-> button-el
                      .-firstChild
                      (set-styles! {:width "35px"
                                    :filter "invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%)"}))
                  button-el)))
         log))
  ;; => #'cljcastr.player/playback-buttons

  (doseq [el playback-buttons]
    (.appendChild playback-controls-container el))
  ;; => nil

  ;; Grab the new buttons
  (def play-button (js/document.querySelector "#button-play"))
  ;; => #'cljcastr.player/play-button

  (def pause-button (js/document.querySelector "#button-pause"))
  ;; => #'cljcastr.player/play-button

  (def forward-button (js/document.querySelector "#button-forward"))
  ;; => #'cljcastr.player/forward-button

  (def backward-button (js/document.querySelector "#button-backward"))
  ;; => #'cljcastr.player/backward-button

  ;; Attach the click handlers to them
  (.addEventListener play-button "click"
                     (fn [ev]
                       (log ev)
                       (.play (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  (.addEventListener pause-button "click"
                     (fn [ev]
                       (log ev)
                       (.pause (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  (.addEventListener forward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-forward! (js/document.querySelector "audio.cljcastr")
                                      seek-dur-sec)))
  ;; => nil

  (.addEventListener backward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-backward! (js/document.querySelector "audio.cljcastr")
                                       seek-dur-sec)))
  ;; => nil

  )

Player with buttons with backgrounds and borders

The only problem with this approach is that our buttons now look like, well, buttons. 😅

Let's restyle them a bit so they look like they did before:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn create-el ...)

(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src click-handler]} ctx]
   (let [button-id (str "button-" (name button-name))
         button-el (create-el {:tag "button"
                               :attrs {:id button-id
                                       :class (render "cljcastr {{button-class}}" ctx)
                                       :aria-label (render label ctx)}})
         img-el (create-el {:tag "img"
                            :attrs {:id (str button-id "-icon")
                                    :class (render "cljcastr {{img-class}}" ctx)
                                    :src src
                                    :draggable false}})]
     (set-styles! button-el {:padding 0
                             :margin 0
                             :border 0
                             :background "transparent"
                             :cursor "pointer"})
     (.addEventListener button-el "click" click-handler)
     (.appendChild button-el img-el)
     button-el)))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (.replaceChildren playback-controls-container)
  ;; => nil

  (def playback-buttons
    (->> [:backward :play :pause :forward :speed]
         (map (fn [button-name]
                [button-name (get playback-controls button-name)]))
         (map (fn [[button-name button]]
                (let [button-el (create-button button-name button
                                               {:seek-dur-sec seek-dur-sec
                                                :button-class "playback-button"
                                                :img-class "playback-icon"})]
                  (-> button-el
                      .-firstChild
                      (set-styles! {:width "35px"
                                    :filter (str "invert(59%) sepia(92%) saturate(611%) "
                                                 "hue-rotate(343deg) brightness(97%) contrast(99%)")}))
                  button-el)))
         log))
  ;; => #'cljcastr.player/playback-buttons

  (doseq [el playback-buttons]
    (.appendChild playback-controls-container el))
  ;; => nil

  (def play-button (js/document.querySelector "#button-play"))
  ;; => #'cljcastr.player/play-button

  (def pause-button (js/document.querySelector "#button-pause"))
  ;; => #'cljcastr.player/play-button

  (def forward-button (js/document.querySelector "#button-forward"))
  ;; => #'cljcastr.player/forward-button

  (def backward-button (js/document.querySelector "#button-backward"))
  ;; => #'cljcastr.player/backward-button

  (.addEventListener play-button "click"
                     (fn [ev]
                       (log ev)
                       (.play (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  (.addEventListener pause-button "click"
                     (fn [ev]
                       (log ev)
                       (.pause (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  (.addEventListener forward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-forward! audio seek-dur-sec)))
  ;; => nil

  (.addEventListener backward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-backward! audio seek-dur-sec)))
  ;; => nil

  )

Now our buttons look like they did before! 🎉

However, recreating these buttons is getting a little tedious. Let's do some cleaning up.

Sweeping up the shop

First of all, our log function is a little limited. It would be nice if we could include a log message in addition to whatever data we're logging. In other words, we'd like to do this:

(comment

  ;; ...

  (log "Something happened!" {:foo 1, :bar 2})
  ;; => "Something happened!"

  )

But if we look at the console now, we only see the message logged and not the data:

  Something happened!                                                 nrepl.cljs:10

Luckily, the fix is pretty straightforward:

(ns cljcastr.player
  (:require [clojure.string :as str]))

(defn log [& args]
  (apply js/console.log (map clj->js args))
  (last args))

;; (defn render ...)
;; ...

(comment

  ;; ...

  (log "Something happened!" {:foo 1, :bar 2})
  ;; => {:foo 1, :bar 2}

  )

The value returned is what we wanted, and the console looks good as well:

  Something happened!  ‣ {foo: 1, bar: 2}                             nrepl.cljs:10

Next annoyance: all those js/document.querySelector calls. Extraction!

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn render ...)

(defn get-el
  "Returns the element matching the specified CSS selector. If `selector` is
   already an element, just returns the element."
  [selector]
  (if (instance? js/Element selector)
    selector
    (js/document.querySelector selector)))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (get-el "#button-play")
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

Might as well have a function to get a list of elements too:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-el ...)

(defn get-els
  "Returns a sequence containing the elements matching the specified CSS
   selector."
  [selector]
  (->> (js/document.querySelectorAll selector)
       seq))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (get-els "#playback-controls > button")
  ;; => (#object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]])

  )

How about one to return the children of an element?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-els ...)

(defn get-children [el]
  (-> (get-el el)
      .-childNodes
      seq))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (-> (get-children "#playback-controls")
      log)
  ;; => (#object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]])

  )

We added another nicety there: by using get-el, our function can take either an element or a CSS selector as its argument. Let's continue to use this pattern as we add a function for adding a child to an element:

But if we look at the console, we see something extremely odd:

                                                                      nrepl.cljs:10
  ‣ he {value: button#backward, Ee: ArrayIterator, Ag: null, F: 8388672, M: 0}

The answers, my friend, are blowing in the wind of get-children:

(defn get-children [el]
  (-> (get-el el)
      .-childNodes
      seq))

We're taking the childNodes attribute of our HTMLDivElement, which is a NodeList object:

(comment

  ;; ...

  (-> (get-el "#playback-controls")
      .-childNodes
      log)
  ;; => #object[NodeList [object NodeList]]

  )

This looks great in the console:

                                                                      nrepl.cljs:10
    NodeList(5) [button#button-backward.cljcastr.playback-button,
    button#button-play.cljcastr.playback-button,
  ‣ button#button-pause.cljcastr.playback-button,
    button#button-forward.cljcastr.playback-button,
    button#button-speed.cljcastr.playback-button]

But we can't use it in Clojure all that well:

(comment

  ;; ...

  (-> (get-el "#playback-controls")
      .-childNodes
      count)
  ;; => "#error {:message \"No protocol method ICounted.-count defined for type object: [object NodeList]\", ...}"

  )

That's why get-children adds the call to seq at the end. That lets normal Clojure code deal with the list of children, but as we've seen, it doesn't look good in the console when logged.

Let's see what we can do about this:

(comment

  ;; ...

  (-> (get-children "#playback-controls")
      vec
      log)
  ;; => [#object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]]

  )

Now the console is looking quite reasonable!

                                                                      nrepl.cljs:10
    (5) [button#button-backward.cljcastr.playback-button,
    button#button-play.cljcastr.playback-button,
  ‣ button#button-pause.cljcastr.playback-button,
    button#button-forward.cljcastr.playback-button,
    button#button-speed.cljcastr.playback-button]

Let's see if we can apply what we've learned here to our log function:

(ns cljcastr.player
  (:require [clojure.string :as str]))

(defn log [& args]
  (let [seq->vec #(if (seq? %) (vec %) %)]
    (apply js/console.log (map (comp clj->js seq->vec) args)))
  (last args))

  ;; (defn render ...)
  ;; ...

(comment

  ;; ...

  (-> (get-children "#playback-controls")
      log)
  ;; => (#object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]])

  )
(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-children ...)

(defn add-child! [parent-el el]
  (let [parent-el (get-el parent-el)]
    (-> parent-el
        (.appendChild el))
    parent-el))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (->> (add-child! "#playback-controls"
                   (create-el {:tag "span"
                               :text "Some nonsense"}))
       (log "I got a new child"))
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Wanna clear all the children of an element?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-child! ...)

(defn clear-children! [el]
  (let [el (get-el el)]
    (.replaceChildren el)
    el))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (->> (clear-children! "#playback-controls")
       (log "Empty nest"))
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Set the children of an element?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn clear-children! ...)

(defn set-children! [parent-el els]
  (let [parent-el (get-el parent-el)]
    (clear-children! parent-el)
    (doseq [el els]
      (add-child! parent-el el))
    parent-el))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (->> (set-children! "#playback-controls" playback-buttons)
       log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Final annoyance: all the repetition in the click handlers. Let's extract a function for that as well:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn create-el ...)

(defn add-event-listener! [el event-type f]
  (let [el (get-el el)]
    (.addEventListener el event-type
                       (fn [ev]
                         (log (str "Handling " event-type " on")
                              el ev)
                         (if f
                           (f ev)
                           (log "No event handler function defined"))))
    (log "Added listener for" event-type "events to" el)
    el))

;; (defn create-button ...)
;; ...

(comment

  ;; ...

  (add-event-listener! "#button-speed" "click"
                       #(log "Should probably do something with this event, eh?" %))
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

And if we now click the playback speed button, the console rewards us with some rich logging:

Console log of event handler

With all these awesome functions, we can finally recreate our buttons much more concisely:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-event-listener! ...)

(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src click-handler]} ctx]
   (let [button-id (str "button-" (name button-name))]
     (-> (create-el {:tag "button"
                     :attrs {:id button-id
                             :class (render "cljcastr {{button-class}}" ctx)
                             :aria-label (render label ctx)}})
         (set-styles! {:padding 0
                       :margin 0
                       :border 0
                       :background "transparent"
                       :cursor "pointer"})
         (add-event-listener! "click" click-handler)
         (add-child! (create-el {:tag "img"
                                 :attrs {:id (str button-id "-icon")
                                         :class (render "cljcastr {{img-class}}" ctx)
                                         :src src
                                         :draggable false}}))))))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (def playback-controls
    {:play {:src "https://www.svgrepo.com/show/436852/play-circle-fill.svg"
            :label "Play"
            :click-handler #(.play (get-el "audio.cljcastr"))}
     :pause {:src "https://www.svgrepo.com/show/436829/pause-circle-fill.svg"
             :label "Pause"
             :click-handler #(.pause (get-el "audio.cljcastr"))}
     :forward {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
               :label "Forward {{seek-dur-sec}} seconds"
               :click-handler #(skip-forward! (get-el "audio.cljcastr") seek-dur-sec)}
     :backward {:src "https://www.svgrepo.com/show/495036/backward-15-seconds.svg"
                :label "Back {{seek-dur-sec}} seconds"
                :click-handler #(skip-backward! (get-el "audio.cljcastr") seek-dur-sec)}
     :speed {:src "https://www.svgrepo.com/show/151785/speed-meter.svg"
             :label "Playback speed"}})
  ;; => #'cljcastr.player/playback-controls

  (def playback-buttons
    (->> [:backward :play :pause :forward :speed]
         (map (fn [button-name]
                [button-name (get playback-controls button-name)]))
         (map (fn [[button-name button]]
                (let [button-el (create-button button-name button
                                               {:seek-dur-sec seek-dur-sec
                                                :class "playback-control"})]
                  (-> (get-children button-el)
                      first
                      (set-styles! {:width "35px"
                                    :filter "invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%)"}))
                  button-el)))
         log))
  ;; => #'cljcastr.player/playback-buttons

  (set-children! "#playback-controls" playback-buttons)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

OK, so we've fixed the non-obvious (to those of us not using screen readers) problem with the player. Scratching our heads, we try to recall what the obvious problem was. 🤔

Oh yeah! Two sets of controls, right. Let's fix.

Sweeping audio under the carpet

Making our audio element disappear isn't that hard. Let's use this as an excuse to create a few more helper functions and spread the get-el pattern a little wider as well:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-els ...)

(defn get-attr
  "Returns the value of the specified attribute."
  [el attr]
  (let [el (get-el el)]
    (.getAttribute el (name attr))))

(defn set-attr!
  "Sets the value of the specified attribute."
  [el attr v]
  (let [el (get-el el)]
    (.setAttribute el (name attr) v)
    el))

(defn set-attrs! [el attrs]
  (let [el (get-el el)]
    (doseq [[k v] attrs]
      (.setAttribute el (name k) v))
    el))

(defn get-id
  "Returns the ID of an element."
  [el]
  (let [el (get-el el)]
    (.-id el)))

(defn set-id!
  "Sets the ID of an element."
  [el id]
  (let [el (get-el el)]
    (when-not (empty? id)
      (set-attr! el :id id))
    el))

(defn get-name
  "Returns the tag name of an element."
  [el]
  (let [el (get-el el)]
    (.-localName el)))

(defn get-text
  "Returns the inner text of an element."
  [el]
  (let [el (get-el el)]
    (or (.-textContent el)
        (.-innerText el))))

(defn set-text! [el text]
  (let [el (get-el el)]
    (when-not (empty? text)
      (set! (.-textContent el) text))
    el))

(defn get-classes
  "Returns the classes of the specified element as a list of class names."
  [el]
  (let [el (get-el el)]
    (->> (str/split (.-classList el) #"\s")
         (remove empty?))))

(defn set-classes!
  "Sets the classes of the specified element. Classes may either be a list or a
   string.

   Example:

     ;; These two are equivalent
     (set-classes el [\"foo\" \"bar\"])
     (set-classes el \"foo bar\")"
  [el classes]
  (let [el (get-el el)]
    (when-not (empty? classes)
      (set! (.-classList el) (if (string? classes)
                               classes
                               (str/join " " classes))))
    el))

;; (defn get-children ...)
;; ...
;; (defn set-children! ...)

(defn get-styles
  "Returns the inline styles of an element as a map of style name to value."
  [el]
  (let [styles (-> (get-el el)
                   (.getAttribute "style")
                   (str/split #";\s+"))]
    (->> styles
         (remove empty?)
         (map #(let [[k v] (str/split % #":\s+")]
                 [(-> (str/trim k) keyword) (str/trim v)]))
         (into {}))))

(defn set-styles!
  "Sets the styles of an element from a map of style name to value."
  [el styles]
  (let [el (get-el el)]
    (when-not (empty? styles)
      (set! (.-style el) (->> styles
                              (map (fn [[k v]]
                                     (str (name k) ": " v)))
                              (str/join "; "))))
    el))

(defn add-styles!
  "Given a map of style name to value, adds the styles to the element."
  [el styles]
  (-> el
      (set-styles! (merge (get-styles el) styles))))

(defn add-style!
  "Adds a style to an element"
  [el style-name style-value]
  (-> el
      (add-styles! {style-name style-value})))

;; (defn create-el)
;; ...

Now that we have all of this awesomeness, we can streamline create-el by accepting a few more keys:

  • :id "foo" instead of :attrs {:id "foo"}
  • :classes ["foo" "bar"] instead of :attrs {:class "foo bar"}
  • :text to set the text content of the element

We also have to update create-button to take advantage of this.

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-style! ...)

(defn create-el [{:keys [tag id classes text attrs] :as args}]
  (-> (js/document.createElement tag)
      (set-attrs! attrs)
      (set-id! id)
      (set-classes! classes)
      (set-text! text)))

;; (defn add-event-listener! ...)
;; ...

(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src click-handler]} ctx]
   (let [button-id (str "button-" (name button-name))]
     (-> (create-el {:tag "button"
                     :id button-id
                     :classes ["cljcastr"
                               (render "{{button-class}}" ctx)]
                     :attrs {:aria-label (render label ctx)}})
         (set-styles! {:padding 0
                       :margin 0
                       :border 0
                       :background "transparent"
                       :cursor "pointer"})
         (add-event-listener! "click" click-handler)
         (add-child! (create-el {:tag "img"
                                 :id (str button-id "-icon")
                                 :classes ["cljcastr"
                                           (render "{{img-class}}" ctx)]
                                 :attrs {:src src
                                         :draggable false}}))))))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (add-style! audio :display "none")
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  (def playback-buttons
    (->> [:backward :play :pause :forward :speed]
         (map (fn [button-name]
                [button-name (get playback-controls button-name)]))
         (map (fn [[button-name button]]
                (let [button-el (create-button button-name button
                                               {:seek-dur-sec seek-dur-sec
                                                :button-class "playback-button"
                                                :img-class "playback-icon"})]
                  (-> (get-children button-el)
                      first
                      (set-styles! {:width "35px"
                                    :filter (str "invert(59%) sepia(92%) saturate(611%) "
                                                 "hue-rotate(343deg) brightness(97%) contrast(99%)")}))
                  button-el)))
         log))
  ;; => #'cljcastr.player/playback-buttons

  (set-children! "#playback-controls" playback-buttons)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Player with just buttons

Testing our controls, we verify that they still work. However, we've lost three important things:

  1. The progress bar, which gave us a visual indication of where we are and a way to move to an arbitrary point in the episode by clicking or dragging.
  2. The display of the current position in the episode.
  3. The display of the duration of the episode.

Actually, on the third point, we like Overcast's choice to display the time remaining instead of the duration, so let's do that as well.

Having made these bold design decisions, let's get to implementing! We'll start with the easy stuff, displaying the time elapsed and remaining:

(comment

  ;; ...

  (def elapsed-time (create-el {:tag "span"
                                :text (str (.-currentTime audio))
                                :classes ["cljcastr"]
                                :id "elapsed-time"}))
  ;; => #'cljcastr.player/cur-time

  (def remaining-time (create-el {:tag "span"
                                  :text (str (- (.-duration audio)
                                                (.-currentTime audio)))
                                  :classes ["cljcastr"]
                                  :id "remaining-time"}))
  ;; => #'cljcastr.player/remaining-time

  (doseq [el [elapsed-time remaining-time]]
    (add-child! "#playback-controls" el))
  ;; => nil

  )

Times displayed as fractional seconds

Well, this is something, at least. Something incredibly confusing and also ugly. 😭

Let's enumerate the problems:

  1. Times are displayed as fractional seconds, not the more familiar h:mm:ss.
  2. The spans are side by side with no padding, so it's not possible to tell where elapsed time ends and remaining time begins.
  3. The font size is too large compared to the buttons. And maybe black isn't the best choice of colour anyway.
  4. We'd like the times displayed above the buttons (and the progress bar, when we get around to adding it).

Now that we know what's wrong, let's tackle things one at a time.

Hours, minutes, and seconds, oh my!

How can we convert from seconds to h:mm:ss?

(comment

  ;; ...

  (set! (.-currentTime audio) 3645)
  ;; => 3645

  (let [secs (.-currentTime audio)
        h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)]
    (str (int h) ":" (int mm) ":" (int ss)))
  ;; => "1:0:45"

  )

OK, not a bad start. Let's zero-pad the minutes and seconds, though, because "1:00:45" looks way better than "1:0:45". 😉

(comment

  ;; ...

  (let [secs (.-currentTime audio)
        h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)
        zero-pad #(-> % int str (str/replace #"^(\d)$" "0$1"))]
    (str (int h) ":" (zero-pad mm) ":" (zero-pad ss)))
  ;; => "1:00:45"

  (set! (.-currentTime audio) 46)
  ;; => 46

  (let [secs (.-currentTime audio)
        h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)
        zero-pad #(-> % int str (str/replace #"^(\d)$" "0$1"))]
    (str (int h) ":" (zero-pad mm) ":" (zero-pad ss)))
  ;; => "0:00:46"

  )

Hrm, maybe one more tweak. It's conventional to omit the hour(s) portion of timestamps less than an hour:

(comment

  ;; ...

  (let [secs (.-currentTime audio)
        h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)
        zero-pad #(-> % int str (str/replace #"^(\d)$" "0$1"))]
    (str (if (pos? (int h))
           (str (int h) ":")
           "")
         (zero-pad mm) ":" (zero-pad ss)))
  ;; => "00:46"

  )

Nice! Now let's turn this into a function:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn render ...)

(defn secs->ts
  "Given a number of seconds, returns a timestamp of the form h:mm:ss."
  [secs]
  (let [h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)
        zero-pad #(-> % int str (str/replace #"^(\d)$" "0$1"))]
    (str (if (pos? (int h))
           (str (int h) ":")
           "")
         (zero-pad mm) ":" (zero-pad ss))))

;; (defn get-el ...)
;; ...

(comment

  ;; ...

  ;; Elapsed time
  (secs->ts (.-currentTime audio))
  ;; => "00:46"

  ;; Remaining time
  (secs->ts (- (.-duration audio) (.-currentTime audio)))
  ;; => "1:28:44"

  (-> elapsed-time
      (set-text! (secs->ts (.-currentTime audio)))
      log)
  ;; => #object[HTMLSpanElement [object HTMLSpanElement]]

  (-> remaining-time
      (set-text! (str "-" (secs->ts (- (.-duration audio) (.-currentTime audio)))))
      log)
  ;; => #object[HTMLSpanElement [object HTMLSpanElement]]
  
  )

OK, we've solved problem number 1. 🎉

Problems 2 and 3 are just styling, so the fixes are pretty straightforward:

(comment

  ;; ...

  (doseq [el [elapsed-time remaining-time]]
    (add-styles! el {:margin "0 5px 0 5px"
                     :font-size "0.7rem"
                     :color "grey"}))
  ;; => nil

  )

Timestamps displayed in smaller, grey text

Whilst we're at it, let's fix another annoyance: with our current create-el function, we either have to call add-styles! (or set-styles!) afterwards, or do the stringifying of the styles ourselves:

(create-el {:tag "div"
            :attrs {:id "whatever"
                    :style "margin: 0 5px 0 5px; font-size: 0.7rm; color: grey"}})

We also have the same issue with classes and ID. 🙁

But instead of wallowing in misery, let's modify create-el to take a :styles key:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-style! ...)

(defn create-el [{:keys [tag id classes styles text attrs] :as args}]
  (-> (js/document.createElement tag)
      (set-attrs! attrs)
      (set-id! id)
      (set-classes! classes)
      (set-styles! styles)
      (set-text! text)))

;; (defn add-event-listener! ...)
;; ...

(comment

  ;; ...

  (let [el (-> (create-el {:tag "span"
                           :id "elapsed-time"
                           :classes ["cljcastr"]
                           :text (secs->ts 123)
                           :styles {:margin "0 5px 0 5px"
                                    :font-size "0.7rem"
                                    :color "grey"}})
               log)]
    (add-child! "#playback-controls" el))
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Now, let's address the last problem.

Laying out the law

It is now time to employ the most awesome technology known to humankind to figure out our layout: ASCII art!

+-----------------------------------+
|            Podcast title          |
+-----------------------------------+
|            Episode title          |
+-----------------------------------+
|            Release date           |
+-----------------------------------+
|            Episode art            |
+-----------------------------------+
|     Elapsed time | Remaining time |
+-----------------------------------+
|            Progress bar           |
+-----------------------------------+
| Backward | Play | Pause | Forward |
+-----------------------------------+
|           Playback speed          |
+-----------------------------------+

We've been using the body element to hold our player, but we should probably deploy a div at this point so we can style things according to our liking. We can also group similar things together in divs, something like this:

+ episode-container ----------------------------+
|                                               |
| + episode-intro ----------------------------+ |
| |                Podcast title              | |
| +-------------------------------------------+ |
| |                Episode title              | |
| +-------------------------------------------+ |
| |                Release date               | |
| +-------------------------------------------+ |
|                                               |
+-----------------------------------------------+
|                  Episode art                  |
+-----------------------------------------------+
|                Audio (hidden)                 |
+-----------------------------------------------+
|                                               |
| + progress ---------------------------------+ |
| |       Elapsed time | Remaining time       | |
| +-------------------------------------------+ |
| |                Progress bar               | |
| +-------------------------------------------+ |
|                                               |
+-----------------------------------------------+
|                                               |
| + playback-controls ------------------------+ |
| | Backward | Play | Pause | Forward | Speed | |
| +-------------------------------------------+ |
|                                               |
+-----------------------------------------------+

Let's start by creating the new nested containers (we already have one for playback controls) and populating them with some hard-coded data:

(comment

  ;; ...

  (def episode-container (create-el {:tag "div"
                                     :id "episode-container"
                                     :classes ["cljcastr"]
                                     :styles {:width "100%"
                                              :display "flex"
                                              :flex-direction "column"
                                              :align-items "center"}}))
  ;; => #'cljcastr.player/episode-container

  (def episode-info
    (-> (create-el {:tag "div"
                    :id "episode-info"
                    :classes ["cljcastr"]
                    :styles {:max-width "420px"
                             :text-align "center"}})
        (set-children! [(create-el {:tag "h3"
                                    :id "podcast-title"
                                    :classes ["cljcastr"]
                                    :text "defn"})
                        (create-el {:tag "h2"
                                    :id "episode-title"
                                    :classes ["cljcastr"]
                                    :text "876b4c306172 Cora Sutton"})
                        (create-el {:tag "span"
                                    :id "episode-date"
                                    :classes ["cljcastr"]
                                    :styles {:color "grey"}
                                    :text "July 9, 2025"})])
        log))
  ;; => #'cljcastr.player/episode-info

  (def episode-art
    (-> (create-el {:tag "div"
                    :id "episode-art-container"
                    :classes ["cljcastr"]})
        (add-child! (create-el {:tag "img"
                                :id "episode-art"
                                :classes ["cljcastr"]
                                :styles {:width "400px"
                                         :border-radius "5%"
                                         :padding "10px"}
                                :attrs {:src "https://media.zencastr.com/image-files/5d9048546bacd9001de88f1c/a5b922ac-7294-4d58-9a33-4308f5d4f50b.png"}}))
        log))
  ;; => #'cljcastr.player/episode-art

  (def progress (create-el {:tag "div"
                            :id "progress"
                            :classes ["cljcastr"]
                            :styles {:width "390px"
                                     :margin "1.5em auto 0 auto"}}))
  ;; => #'cljcastr.player/progress

  (def playback-controls-container (-> (create-el {:tag "div"
                                                   :id "playback-controls"
                                                   :classes ["cljcastr"]})
                                       (set-children! playback-buttons)
                                       log))
  ;; => #'cljcastr.player/playback-controls-container

  (def playback-speed (create-el {:tag "div"
                                  :id "playback-speed"
                                  :classes ["cljcastr"]}))
  ;; => #'cljcastr.player/playback-speed

  (-> episode-container
      (set-children! [episode-info
                      episode-art
                      (-> audio
                          (set-styles! {:display "none"}))
                      progress
                      playback-controls-container
                      playback-speed])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  (set-children! "body" [episode-container])
  ;; => #object[HTMLBodyElement [object HTMLBodyElement]]

  )

Player with defn episode

This is looking really good (relatively speaking), but there's one major component missing.

Slipping and sliding

We have no idea where in the episode we are! Let's see about getting that progress bar in place.

Luckily, HTML has got us covered! There just so happens to be a progress element designed exactly for what we want to do!

(comment

  ;; ...

  (def progress-bar
    (create-el {:tag "progress"
                :id "progress-bar"
                :classes ["cljcastr"]
                :attrs {:min 0
                        :max (.-duration audio)
                        :value (.-currentTime audio)}
                :styles {:appearance "none"
                         :height "8px"
                         :width "100%"
                         :border "0"
                         :border-radius "4px"
                         :margin "0"
                         :overflow "hidden"
                         :background-color "lightgray"
                         :color "#f7931e"}}))
  ;; => #'cljcastr.player/progress-bar

  )

Now that we have the progress bar, let's add it to our #progress div, along with the current and elapsed time spans we created before:

(comment

  ;; ...

  (-> progress
      (set-children! [(-> elapsed-time
                          (add-styles! {:float "left"}))
                      (-> remaining-time
                          (add-styles! {:float "right"}))
                      progress-bar])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Progress bar added

Hurrah! I mean, it's a bit ugly, but at least it's there! 😅

Let's click the play button, then skip forward a few times. Now, we can try setting the value of the progress bar to the current time of the audio element:

(comment

  ;; ...

  (set! (.-value progress-bar) (.-currentTime audio))
  ;; => 370.277385

  )

We should see the progress bar move along a bit.

This is great and all, but it would be lovely to have a way to actually move forward and backward in the episode by clicking or dragging on the progress bar. You know, the way every audio player ever works?

Sadly, the progress bar itself isn't really designed for this. We could certainly hack it up with some "click" event listeners and a lot of arithmetic, but surely there must be a better way, right?

And indeed there is! HTML has an input element of type range that is designed for exactly this case! Let's try it out:

(comment

  ;; ...

  (def progress-slider
    (create-el {:tag "input"
                :id "progress-slider"
                :classes ["cljcastr"]
                :attrs {:type "range"
                        :min 0
                        :max (.-duration audio)
                        :value (.-currentTime audio)
                        :step "any"
                        :aria-label "Seek"
                        :aria-valuetext (str (secs->ts (.-currentTime audio))
                                             " of "
                                             (secs->ts (.-duration audio)))
                        :aria-valuemax (.-duration audio)}
                :styles {:appearance "none"
                         :cursor "pointer"
                         :height "28px"
                         :width "100%"
                         :border "0"
                         :margin "0"
                         :touch-action "manipulation"}}))
  ;; => #'cljcastr.player/progress-slider

  (-> progress
      (set-children! [(-> elapsed-time
                          (add-styles! {:float "left"}))
                      (-> remaining-time
                          (add-styles! {:float "right"}))
                      progress-slider
                      progress-bar])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

OK, a blue dot has appeared. Let's skip forward in the episode a bit more, then update the value of the slider to the current playback position. Oh yeah, might as well update the value of the progress bar as well, whilst we're at it:

(comment

  ;; ...

  (doseq [el [progress-slider progress-bar]]
    (set! (.-value el) (.-currentTime audio)))
  ;; => nil

  )

Now, how about setting the playback position in the episode when we click or drag the slider?

(comment

  ;; ...

  (add-event-listener! progress-slider "input"
                       #(->> % .-target .-value (set! (.-currentTime audio))))
  ;; => #object[HTMLInputElement [object HTMLInputElement]]

  )

We now have a pretty good representation of playback position:

  • A span showing the elapsed time
  • A span showing the remaining time
  • A slider letting us control the playback position
  • A progress bar giving us a visual indication of the playback position

Let's see if we can keep all of these in sync. First, some code to update them all in one go:

(comment

  ;; ...

  (let [secs (.-currentTime audio)
        duration-secs (.-duration audio)]
    (set-text! elapsed-time (secs->ts secs))
    (set-text! remaining-time (str "-" (secs->ts (- duration-secs secs))))
    (.setAttribute progress-slider "aria-valuetext"
                   (str (secs->ts secs) " of " (secs->ts duration-secs)))
    (doseq [el [progress-slider progress-bar]]
      (set! (.-value el) (.-currentTime audio))))
  ;; => nil

  )

Looks like that works, so let's extract it into a function. And whilst we're at it, maybe we can extract functions for the attribute and value setting so we don't have to expose the raw DOM interactions. And for the sake of completeness, we can add getters as well:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn set-text! ...)

(defn get-value
  "Returns the value attribute of the specified element."
  [el]
  (let [el (get-el el)]
    (.-value el)))

(defn set-value!
  "Sets the value attribute of the specified element."
  [el v]
  (let [el (get-el el)]
    (set! (.-value el) v)))

;; (defn set-classes! ...)
;; ...
;; (defn skip-backward! ...)

(defn update-playback-position-indicators!
  "Given a current playback time and duration (both in seconds) and a map of
   elements, updates the elements to reflect the current playback position. The
   element map should contain the following keys:
   `:elapsed-time` - text element (for example: label, span, etc.)
   `:remaining-time` - text element (for example: label, span, etc.)
   `:progress-slider` - input element of type range
   `:progress-bar` - progress element"
  [current-secs duration-secs
   {:keys [elapsed-time remaining-time progress-slider progress-bar]}]
  (set-text! elapsed-time (secs->ts current-secs))
  (set-text! remaining-time (str "-" (secs->ts (- duration-secs current-secs))))
  (set-attr! progress-slider :aria-valuetext
             (str (secs->ts current-secs) " of " (secs->ts duration-secs)))
  (doseq [el [progress-slider progress-bar]]
    (set-value! el current-secs)))

;; ...

(comment

  ;; ...

  (update-playback-position-indicators! (.-currentTime audio) (.-duration audio)
                                        {:elapsed-time elapsed-time
                                         :remaining-time remaining-time
                                         :progress-slider progress-slider
                                         :progress-bar progress-bar})
  ;; => nil

  )

There's one final touch we should apply here before we move on. It's getting a bit silly to have to manually update these elements, right? How about we update them automatically as the episode plays? For this, we can use the timeupdatechange event of our audio element:

(comment

  ;; ...

  (add-event-listener! audio "timeupdate"
                       #(update-playback-position-indicators!
                         (.-currentTime audio) (.-duration audio)
                         {:elapsed-time elapsed-time
                          :remaining-time remaining-time
                          :progress-slider progress-slider
                          :progress-bar progress-bar}))
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  )

Now things are running smoothly, but there's another thing that's bothering us. The progress slider that we use to control the playback position is above the progress bar, which just looks weird, and also goes against the expectations of our hordes of users that there should be a handle on the progress bar itself that they can drag around. You know, like this:

Overcast web player

Getting into position

What we'd really like is to set our slider down right on top of the progress bar. This will give us the seeking we seek (sorry) as well as a visual indication of the current playback position. In order to do this, we're going to have to learn a bit about CSS positioning.

Basically, we'll need to create a new container to hold the progress bar and the slider, and set its position style to "relative". Then, we can add our progress bar and slider as children of it, setting their position to "absolute" (relative to their parent container). Yes, I know, this is enough to make our heads explode, but I guess it makes sense once you achieve enlightenment or something?

We also need to set the z-indexes of the two elements. We set the progress bar to 0 to put it at the bottom of the stack, and the slider to 1 so it's on top. That means we need to make the background colour of the slider transparent so the progress bar can shine through in all its glory.

Finally, we position both elements 0 pixels from the left of the enclosing container, then the slider 0 pixels from the top and the progress bar 10 pixels from the top. Why 10 pixels? Well, because:

  • The slider is 28 pixels high.
  • The progress bar is 8 pixels high.
  • We want to vertically centre the progress bar underneath the slider.
  • 20 - 8 is 10.

With that advanced mathematics out of the way, let's make it happen!

(comment

  ;; ...

  (def progress-container
    (create-el {:tag "div"
                :id "progress-container"
                :classes ["cljcastr"]
                :styles {:position "relative"
                         :height "28px"
                         :clear "both"}}))
  ;; => #'cljcastr.player/progress-container

  (-> progress-container
      (set-children! [(-> progress-bar
                          (add-styles! {:position "absolute"
                                        :top "10px"
                                        :left "0px"
                                        :z-index 0}))
                      (-> progress-slider
                          (add-styles! {:position "absolute"
                                        :top "0px"
                                        :left "0px"
                                        :z-index 1
                                        :background-color "transparent"}))])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  (-> progress
      (add-styles! {:position "relative"})
      (set-children! [(-> elapsed-time
                          (add-styles! {:float "left"}))
                      (-> remaining-time
                          (add-styles! {:float "right"}))
                      progress-container])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Player with a nice progress bar and slider

This is looking pretty good! There are a few more things we might want to do, but just as we're about to start doing some of those things, a bone-chilling thought occurs to us: what if we reload the page? We've been building our player up in the REPL one s-expression at a time, but our HTML file still has a body that looks like this:

<body>
</body>

Should a feline friend prance across the keyboard, stepping on the Ctrl and r keys simultaneously (or Apple-r, if you're one of those too-cool-for-school types), all will be lost!

Before giving in to despair, we realise that we have the awesome power of ClojureScript at our fingertips, and thus can make a permanent record of what we've done so far. And that, my dear readers, we shall endeavour to do in the new post in this series, which shall be called Scittlecastr: All You Need is Persistence.

The preview image for this post was based on the Arab woman listening to music with earphones and dancing outdoors photo by Javier Sanchez Mingorance from his Woman In Overall Dress photoshoot. The photo is used under The Noun Project's Royalty-Free Commercial License, and was remixed by hand in the GIMP, which does not allow genAI contributions.

nREPL, Right in Your Browser

Last week nrepl.org got a makeover. The docs moved to docs.nrepl.org, and nrepl.org itself became a landing page that explains what nREPL is and how its protocol works, with “wire windows” showing real exchanges between a client and a server, as EDN or as the bencode that actually goes over the wire. Reading about a protocol only gets you so far, though, so the page now also has a “Try it” section with a real nREPL server running right in your browser. There’s one in this post as well! In the rest of it I’ll tell you how it came about, why it was harder than it looks, and how you can use the server behind it for your own projects.

Here it is:

user=>
(+ 1 2) (println "Hello from nREPL!") (map inc (range 10)) (doc map) (range) interrupt show the messages

Go ahead, evaluate something! Tick “show the messages” to see the requests and the responses going back and forth, and try (range) if you’re curious what happens to code that never finishes.

Read moreShow less

Picking an approach

An interactive nREPL playground always runs into the same problem: you need an nREPL server somewhere, and a server that evaluates code from random people on the internet is just about the worst thing you can host. (It would also cost money that I’d rather spend on something else.) So the server had to run in the browser, and I looked at a few ways to get there.

The first one was the real thing. CheerpJ is a JVM that runs in the browser, and nREPL 1.8 and Clojure 1.12 worked on it remarkably well. Every op I tried worked, stdin included! The problem was everything around that - about 8 seconds to a ready server on a fast machine, about 4.5MB to download, a busy loop that freezes the whole page unless you put the JVM in a Web Worker, and an interrupt that works only on code that’s sleeping.1 That’s a lot to ask of someone who just opened a landing page.

The second one was scittle, which runs SCI (Michiel Borkent’s Clojure interpreter) in the browser and comes with an nREPL server, sci.nrepl. It solves a different problem, though - it lets your editor evaluate code in a page, through a relay that runs in babashka or on the JVM, and its replies differ from nREPL’s in quite a few places. (I’ve filed some issues about those.) nbb has a pretty complete nREPL server of its own, but it runs only on Node.

None of those fit, so I ended up writing a small nREPL server for SCI that runs entirely in a web page, with the page as its client - nrepl-sci. SCI does all the heavy lifting, and evaluation, completion and docs lookup took just a few hundred lines of ClojureScript. The whole thing is about 230KB gzipped and it’s ready in a fraction of a second. Not bad, right?

The hard part

A basic nREPL server is easy enough. The trouble starts with two ops most people never think about - interrupt and stdin.

JavaScript gives you one thread, and SCI evaluates code in one go. While (loop [] (recur)) runs nothing else does, so the server can’t even read the interrupt request that would stop it, and code calling read-line can’t wait for input without blocking the very thread that would receive it. There’s a third problem hiding in there as well. nREPL sessions are supposed to be independent, so an eval in one session (say, your editor’s) shouldn’t hold up another one (say, a test runner connected to the same server).

Here’s how nrepl-sci deals with all of that:

  • The server runs in two Web Workers. A supervisor takes the requests, queues them, and answers interrupt and stdin itself, while an evaluator runs SCI.
  • The two share some memory (a SharedArrayBuffer) that serves as a mailbox. The evaluator waits for mail with Atomics.wait, which blocks a worker without spinning, so read-line waits for its input there, much like it would on the JVM.
  • SCI calls a hook of your choice on every function call and every recur. nrepl-sci uses it to check its mailbox, to see if it’s been interrupted, or if there’s a request from another session it could take in between. Checking on every call made code noticeably slower, so it checks on every 256th call, which still means every few microseconds. Benchmarks run about as fast as without any of this.
  • Endless seqs like (range) and (iterate inc 0) check as they go, so (count (range)) stops when you interrupt it.
  • Some code never calls back into SCI - a regex that backtracks forever, for instance. The supervisor gives it a second to stop, then kills the evaluator and starts a new one. Your sessions survive that, but everything you evaluated in them is gone.

In the end the supervisor, the evaluator and the mailbox took more code than the nREPL server itself, which tells you something about where the complexity is.

Note: All of this works only when the page is cross-origin isolated, as browsers hand out SharedArrayBuffer only to pages served with the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers.

Sadly, GitHub Pages, which hosts both nrepl.org and this blog, can’t send those. Without them nrepl-sci falls back to doing one thing at a time: read-line throws, and interrupting code means starting over, which is exactly what you saw if you tried (range) above. Evaluation, output, errors, completion and doc work the same either way, though.

Testing with proof

Back in 2023 I listed the lack of a protocol compatibility test suite among the things people hate about nREPL, and asked if one of you was looking for a fun small OSS project. Eventually I took my own advice and started proof, and nrepl-sci is the first server I’ve written with proof around from day one. It made a huge difference! Building an nREPL server used to involve a lot of guessing, as the protocol is small, but what clients actually expect from a server is spread across their source code.

A proof profile is a short TOML file that tells proof how to start your server and how to say a few things in its language: a value, some output, an exception, reading a line, code that runs until it’s stopped. proof run takes it from there, with checks covering the protocol and the exact requests CIDER, Calva, Conjure, vim-fireplace, REPLy and neorepl send. When a check fails, proof also tells you why it matters:

FAIL   robust.sessions-at-once      An eval in one session doesn't hold up the others
       clone request got no reply with "done" within 10s
       why: Each client has sessions of its own (e.g. an editor and neorepl,
       connected to the same server), so a server that runs one eval at a time
       leaves every other client waiting for whatever the code in one of them
       does.

That’s a check you can easily shrug off when you’re writing a server for a playground, and it’s the reason nrepl-sci takes requests in between at all. Working on the server mostly came down to running make proof after every change. Today nrepl-sci passes all 73 checks that apply to it (the other 5 are about things like vim-fireplace, which needs Java). Without shared memory it fails exactly two: stdin.need-input and robust.sessions-at-once. I guess that’s a pretty good summary of what you give up without cross-origin isolation.

If you’re working on an nREPL server of your own, I can’t recommend proof enough. It also comes with a GitHub Action, so your CI can run it as well.

The tradeoffs

Nothing comes for free, and nrepl-sci is no exception:

  • It’s SCI, not Clojure. You’re getting ClojureScript as interpreted by SCI, so there are no Java classes, no JVM libraries, and some corners of the language behave differently. That’s fine for a playground, but not for anything that needs your real project.
  • There’s no JavaScript interop. SCI blocks it unless you allow it, and nrepl-sci doesn’t, so (js/alert "hi") and even (.toUpperCase "hi") fail. Admittedly, that’s a bit annoying, but it’s also what makes nrepl-sci safe to embed anywhere, as the code people type can’t touch the page, or even the worker it runs in. (clojure.string/upper-case works just fine.)
  • A request from another session runs on top of the code it interrupted, so that code can’t stop until the request is done, and a read-line taken in between holds it until it gets its input. You’d need two clients fighting over the same server to notice.
  • Starting over loses your definitions, which matters most without cross-origin isolation, where every interrupt starts over.
  • It’s about 1MB of JavaScript (230KB gzipped), loaded the first time you use the REPL. That’s nothing compared to CheerpJ, but it’s not free either.

Using nrepl-sci yourself

nrepl-sci was built for nrepl.org, but nothing in it is specific to the site.

You can embed a REPL in a page - documentation, a tutorial, a blog post. The worker takes the bencode of each request as a Uint8Array and posts back the bencode of each reply, so the whole integration boils down to something like this:

const worker = new Worker("/assets/js/nrepl-sci-worker-0.1.0.js");
worker.onmessage = (event) => console.log(decode(event.data));
worker.postMessage(new TextEncoder().encode(encode({ op: "eval", code: "(+ 1 2)", id: "1" })));

encode and decode are a few dozen lines of bencode, and the REPL in this post is about 170 lines in total, styles included - have a look. Grab nrepl-sci-worker.js from the latest release and serve it from your own site, as browsers start workers only from the page’s own origin. If you can send the cross-origin isolation headers, you get stdin and proper interrupts for free. nrepl.org’s playground is a bigger example, with completion, history and the wire windows.

You can also test nREPL clients without a JVM. The Node build is a regular nREPL server on a socket (node nrepl-sci.js --port 7888) that’s up in about a tenth of a second, which makes it handy for the test suites of clients and editor plugins.

Finally, if you’re after an nREPL server for some other language that runs on a single thread, the supervisor and the mailbox might give you a few ideas. The server itself knows nothing about threads or sockets, and the trick with the mailbox works for any interpreter that can call back now and then while it runs.

What’s next

The obvious next step is getting cross-origin isolation on nrepl.org. A service worker can add the headers GitHub Pages won’t send, at the cost of a reload when it’s first installed, so I might try that on a page of its own. I’d also like to allow some harmless interop, so that (.toUpperCase "hi") works as people expect, and to publish nrepl-sci on npm.

Epilogue

Back in 2018 I wrote that there was “the big dream to port nREPL to ClojureScript”, so it could be used in self-hosted environments, and in 2023 I was still hoping we’d see more native ClojureScript nREPL servers. nrepl-sci isn’t quite that dream - it’s a much smaller nREPL server for SCI - but it’s a step in that direction.

Big thanks to Michiel Borkent for SCI, which did most of the hard work here. None of this would exist without it! And, as usual, special thanks to Clojurists Together for supporting my work on nREPL!

That’s all I have for you today. Play with the REPL, try to break it, and let me know how it went! Keep hacking!

  1. CheerpJ’s free tier also covers only non-commercial open-source projects that load it from Leaning Technologies’ CDN. That would have been fine for nrepl.org, but it’s one more string attached. ↩

OSS updates September and October 2026

In this post I'll give updates about open source I worked on during September and October 2026.

To see previous OSS updates, go here.

Sponsors

I'd like to thank all the sponsors and contributors who make this work possible. Without you, the projects below would not be as mature or would not exist or be maintained at all! So a sincere thank you to everyone who contributes to the sustainability of these projects.

gratitude

Current top tier sponsors:

Open the details section for more info about sponsoring.

Sponsor info

If you want to ensure that the projects I work on are sustainably maintained, you can sponsor this work in the following ways. If you work for a company that uses my OSS, please ask your employer, that would be even better. Thank you!

Read moreShow less

Intro

A lot happened again in the last two months! One major highlight was my visit to the Clojure Conj together with Rahul Dé, where we organized a Babashka workshop. It was great seeing lots of familiar and new faces over there. To give people at the workshop the best experience possible, lots of improvements went into babashka prior to the Conj... workshop driven development I guess. The major milestones I focused on during the last 6-8 months were:

  • Automatic command line help and completions in babashka tasks via babashka.cli
  • Resolving dependencies without a JVM (by vendoring tools.deps and rewriting the maven part)
  • Babashka FFI: a foreign function library to call C-ABI functions from libraries
  • Better nREPL support: support popular nREPL middleware like CIDER's test middleware, the CIDER inspector, etc
  • TUI support via JLine

All of this combined made for a great workshop, where we developed a stateless password manager that used cryptography either by shelling out or by FFI. This project combined the most-used libraries in babashka, like babashka.fs, babashka.process, babashka.cli, babashka.ffi, etc. so even if you didn't end up using this password manager (most people probably didn't) you certainly were exposed to what you would typically use in a bb script or project. Rahul and I will give this workshop at EuroClojure 2027 as well, so in case you missed it, you'll get one more chance!

Babashka workshop at Clojure/conj 2026, seen from the back of the roomRich Hickey and Rahul Dé getting his hamster hammock signed at Clojure/conj 2026

Highlights

Here are some personal favorite highlights from the last two months.

  • Squint

  • Antq, a tool to update outdated deps, now runs with bb!

  • You can now run build.clj in babashka with tools.build (which comes bundled with bb) and get --help and shell completions for your build tasks. Sean Corfield has been adopting this in projects he maintains. See for example: next.jdbc, HoneySQL, logging4j2 and rephrase

  • Babashka.ffi

    • First Clojars release on 2026-09-18, for the JVM, native image, CLJS and nbb. Node.js 26.1+ through node:ffi
    • JVM performance PRs by @Ramblurr (#36, #37)
    • Adopted by sqlite4clj, which moved from coffi to babashka.ffi (#26, @Ramblurr)
  • Clj-kondo

    • Type system improvements: (when (and (string? x) (pos? n)) (inc x)) will now warn since (string? x) implies x is a string and (inc x) will crash on that input
    • :unknown-map-key linter that warns when requesting keys from maps that provably aren't there: given (defn opts [] {:host "localhost"}), (:hots (opts)) will warn.

Blog posts

Blog posts that I published in this period:

Updates

Here are some highlights per project. See each project's CHANGELOG.md for the full list.

  • Babashka: native, fast-starting Clojure interpreter for scripting.

    • Resolve dependencies without a JVM by default. Set :deps-resolver :jvm in bb.edn or the add-deps map, or BABASHKA_DEPS_RESOLVER=jvm, to use Java as before. See the blog post
    • Make clojure.tools.deps and its script namespaces, including clojure.tools.deps.script.make-classpath2, available to scripts
    • Bundle clojure.tools.build.api, with patched install and javac for babashka. A tools.build dependency on the classpath takes precedence and runs from source
    • Add bb repl --connect [addr] to connect to an nREPL server with completion, eldoc, documentation lookup and Ctrl-C interruption. Accepts host:port, a port or unix://path. Reads .nrepl-port by default.
    • Bind the nREPL server to 127.0.0.1 by default instead of 0.0.0.0. Use bb --nrepl-server 0.0.0.0:1667 in Docker
    • Bundle nREPL ops for the CIDER inspector, test runner (C-c C-t) and cider-version
    • Add the nREPL interrupt op. Interruption is cooperative and does not stop tight loops
    • Smaller binary: the bundled Clojure sources are stored gzipped, and jline's class files are no longer embedded a second time as resources.
    • Add the CIDER debugger to the nREPL server: #dbg, #break, C-u C-M-x and the step commands
    • Add CIDER's track-state to the nREPL server: font-lock of loaded vars and the box around instrumented functions
    • Add babashka.deps.maven with find-versions, with-repository-credentials, active-proxy and model-repos
    • Block the certificates of the eMudhra intermediate CA compromise (@stathissideris)
    • The macOS amd64 binary requires macOS 11.0 or later
    • Full changelog
  • babashka.ffi: call C functions in shared libraries from Clojure.

    • 0.1.1: First standalone release of babashka.ffi, available on Clojars for JVM Clojure, ClojureScript and nbb
    • Call C libraries from JVM Clojure on JDK 25 or newer, or from applications compiled with GraalVM 25 or newer. ClojureScript, nbb and shadow-cljs can call C libraries on Node.js 26.1 or newer through node:ffi
    • #63: callback accepts struct layouts as argument and return types
    • JVM call performance by @Ramblurr (#36, #37)
    • New examples: JavaScriptCore, pinball, SDL3, libusb and QuickJS
  • babashka.esbuild: use esbuild from babashka and the JVM

    • New project. Bundle and transform JavaScript, TypeScript, JSX and CSS through babashka.ffi, without Node.js
    • Expose esbuild's metafile and metafile analysis (#3, @lread)
    • Used by cljdoc and hyper
  • deps-deploy: deploy jars to Clojars and Maven Central from babashka

    • New project. Takes the options of slipset/deps-deploy, with transfers through babashka.http-client
    • GPG signing, local install, snapshots and several jars in one deploy
    • Maven Central through the Publisher Portal
  • SCI: Configurable Clojure/Script interpreter suitable for scripting

    • Clojure 1.13 map destructuring as of clojure/clojure 33fb064d (1.13.0-alpha11): :req!, :excess and :missing directives, :defaults removed. Adds the selector macro to clojure.core.
    • Speed up binding (about 4x on the JVM, 2x in native bb) and try without a catch (about 4x)
    • ns-map is 6x faster and ns-refers 60x
    • Fix #1094: a call to a multi-arity fn created a new fn for the called arity on every call, 25x slower than a single-arity call in CLJS
    • CLJS: creating a closure inside a jitted fn is 4x to 9x faster, and creating a multi-arity fn compiles instead of escaping to the interpreter
    • Fix instance? on a protocol returning true for a type the protocol is extended to, such as nil or Object, on the JVM
    • Full changelog
  • clj-kondo: static analyzer and linter for Clojure code that sparks joy.

    • NEW linter :unknown-map-key: warn on a keyword lookup of a key not present in a map. E.g. (:hots (opts)) will warn when opts returns {:host "localhost"}.
    • Catch up with Clojure 1.13.0-alpha9 destructuring
    • Type checker: a type predicate narrows the type of a local in if-not, cond and and. E.g. (when (and (string? x) (pos? n)) (inc x)) will warn.
    • Type checker: merge and merge-with require a map or nil as first map argument. E.g. (merge [1 2] [3 4]) will warn.
    • #2774: :warn-on-reflection also warns on Java constructor calls. E.g. (Date. x) will warn.
    • #3006, #3014, #3015, #3016: fixes for hooks generated with :clj-kondo/macroexpand-hook: referred macros, helper namespaces, requires without alias and nested hooks (@imrekoszo)
    • #2945: support :order-by, :having and :timeout in Datalevin and Datahike queries
    • #2998: analyze Java 25 class files (@skylightis666)
    • Full changelog
  • Squint: CLJS syntax to JS compiler

    • #453: Add source maps for compiled files
    • #594: support for await in doseq, with ^:await on a binding, (doseq [^:await x xs] ...), or on the binding vector for every binding (#1053, @shaunlebron)
    • Add eduction, implements? and keyword-identical?
    • Optimize compiled output for or, and and reify
    • Optimize compiled output for core predicates such as even? and string? in if, and, or and not
    • #1032: #html: escape attribute values from {:& m} (@mskyan)
    • Playground: Implement draggable resizing of editor and compile panes (#1045, @paintparty)
    • Playground: Support codemirror dark theme across editor, repl output, and compiled code instances (#1045, @paintparty)
    • Full changelog
  • neil: A CLI to add common aliases and features to deps.edn-based projects

    • 0.3.71: neil requires babashka 1.13.229 or later
    • #254: auto-generated CLI help and shell completions via babashka.cli. neil dep --help no longer throws
    • #272: make babashka.neil/-main easier to call from code (@lread)
    • #268: neil dep upgrade LIB upgrades only LIB instead of all deps
    • #267: neil dep add, dep upgrade and dep versions find versions in the :mvn/repos of deps.edn. dep versions no longer lists snapshots
  • babashka CLI: Turn Clojure functions into CLIs!

    • #226: use metadata and docstrings from :fn and :exec-fn vars in babashka and ClojureDart
    • dispatch: add :supplied to the opts map's :org.babashka/cli metadata with the set of option keys given on the command line
    • #233: Generated help no longer prints extra spaces when :prog is not specified (@lread)
    • Full changelog
  • nbb: Scripting in Clojure on Node.js using SCI

    • Add babashka.ffi built-in library for calling C functions. Requires Node.js 26.1 or newer
    • Bump SCI: faster multi-arity function dispatch, bumps edamame to 1.6.44
  • Edamame: configurable EDN and Clojure parser with location metadata and more

    • Performance: speed up parsing plain decimal numbers on the JVM #155
    • Performance: build map literals with transients #159
    • Require Clojure 1.10 or newer #158
  • deps.clj: a faithful port of the Clojure CLI Bash script to Clojure

    • Allow --version, --help, -Sdescribe and a cached -Spath to run without Java.
    • Add *make-classpath-fn* to allow in-process classpath resolution.
    • Catch up with Clojure CLI 1.12.6.1673
  • rewrite-edn: Utility lib on top of rewrite-clj

    • #39: Support namespaced maps like #:a{:x 1}
    • neil#239: Read and update maps with metadata like ^{:x 1} {:a 1}
    • Fix get, assoc, update and dissoc when a map value equals the key
  • http-client: HTTP client for Clojure and babashka

    • #86: uri-with-query no longer decodes the existing query, fragment and user info when :query-params are used (@dylanpulver)
    • #91: decode-body passes a nil body through, e.g. of a 407 to CONNECT
  • fs: file system utility library for Clojure

    • #284: non-recursive match and glob now return immediate child directories, not only files
    • #269: match and glob no longer drop pattern text after a 2nd colon on Unix (@marksto)
    • #279: minimum supported version of Clojure is now 1.11 (@lread)
  • html: HTML generation library inspired by squint's HTML tag

    • Omit attributes whose value is nil, or false in html mode: [:input {:disabled false}] rendered disabled="false", which disables the input. Matches hiccup (#19, @telekid)
    • Render true and false as "true" and "false" for aria-*, data-*, contenteditable, draggable, focusable and spellcheck. Names match in any case, so :spellCheck works as well
  • quickblog: lightweight static blog engine for Clojure and babashka

    • Add :pre-process-markdown-fn and :post-process-html-fn options to transform each post's Markdown and HTML (README) (@jmglov)
  • gh-release-artifact: Upload artifacts to GitHub releases idempotently

    • Throw when Github does not accept an upload. A failed upload no longer leaves a green build
    • Upload the new bytes before touching the existing asset when replacing one
  • Buzz: write a web application with the JVM or babashka only

    • New observe, atom-source and buzz.source/Source API. :watch and invalidate! are gone (#20)
  • raylib-bb-ffi: raylib bindings for babashka via babashka.ffi

    • New experiment, ported with an LLM. Tests whether the examples of raylib-jlt and raylib-clj run in babashka and on the JVM through babashka.ffi
  • cljbang: a Clojure-like language that runs as Emacs Lisp

    • Add for (#12) and juxt (#9, @kpassapk)
    • Fix #10: str/join with a separator and an empty coll returns ""
  • Cherry: Experimental ClojureScript to ES6 module compiler

    • Bump squint: don't crash the nREPL server on a SyntaxError from eval (#208)
  • Cream: Clojure + GraalVM Crema native binary

    • Compare load times, tooling and GraalVM build with babashka (#7)
  • lein-clj-kondo and clj-kondo-bb: released alongside each clj-kondo release

Contributions to third party projects:

  • antq: antq runs on babashka (#316, #380) and is installable through bbin (#386)
  • hyper: compile h/expr against a served squint core, tree-shaken with esbuild. See the blog post. Serve source maps for defc components behind :source-map?
  • nextjournal/markdown: #71: render the link title in the default :link renderer
  • clojure-mode: bind Option+S to unwrap on macOS (#62)

Other projects

These are some other projects I'm involved with, but little to no activity happened in the past two months.

Click for more details

neorepl 0.4: Tests, Inspector, Tracing and Agents

neorepl 0.4 came out last week, and since I never got around to writing about 0.2 and 0.3, it feels like a good moment to look back at what happened since the original announcement. Three releases in two weeks, and neorepl now has most of what I listed under “What’s next” back then - a test runner, a full-screen inspector and stacktrace browser, an MCP server and a skill for coding agents - plus a few things that weren’t on the list at all, like tracing and peeking into your coding agent’s REPL session.

Running tests

Running tests in neorepl

,test in the REPL, or neorepl test in your shell, runs the tests in the namespaces you name, the tests you name or, without either, the whole project’s. Failures come with their file and line, and errors with a stack trace that stops at the test that hit them, instead of wandering off into clojure.test’s guts. ,retest (neorepl retest) reruns only what didn’t pass, which is exactly what you want while you’re fixing things.

Read moreShow less

As usual, the server does the heavy lifting. cider-nrepl runs the tests when it’s around, and so does babashka’s nREPL server, which bundles CIDER’s test ops these days. Without either, neorepl falls back to plain clojure.test, as long as you tell it which namespaces to test. And in a ClojureScript session the same commands run your ClojureScript tests.

$ neorepl test
$ neorepl test my.app-test my.app.db-test
$ neorepl test --format json my.app-test/start-test

Stack traces and the inspector

,stacktrace shows the last error full-screen. Pick a frame, press Enter, and its source opens in your $EDITOR at that line (as a copy, for code in a jar), and when you quit the editor you’re back where you were. By default you see only the frames that matter, and a brings back the rest - the REPL’s own machinery and the repeated frames of the same function. With cider-nrepl, i inspects the cause you’re looking at and d its ex-data. After a test run with errors, ,stacktrace shows those, each under the test it came from. (You can see that at the end of the GIF above.)

,inspect puts cider-nrepl’s inspector (babashka has one too) in full-screen:

The neorepl inspector

The arrow keys (or j and k) move around, Enter digs into a value and Backspace goes back up, [ and ] page through big collections, v shows an object’s fields, P toggles pretty printing and g refreshes values that change, like an atom’s. The handiest one is d, which defines whatever you’re looking at as a var. Dig five levels deep into some big API response, press d, give it a name, and you can play with that piece in the REPL right away.

neorepl inspect does the same one step at a time, for scripts and agents, since the inspector keeps its state in the session:

$ neorepl inspect '(System/getProperties)'
$ neorepl inspect --push 3
$ neorepl inspect --def props

Tracing

Tracing in neorepl

,trace traces a function, or every function in a namespace, and from then on each call shows its arguments and what it returned, nested in the calls it’s part of. It’s often the quickest way to see what some code is actually doing, and there are no printlns to forget to remove afterwards. Calls on other threads, like an HTTP handler’s or a future’s, show up above the prompt too. ,trace without arguments lists what’s traced and ,untrace stops tracing. Scripts and agents get neorepl trace, with the calls showing up in the output of the neorepl eval that made them. Tracing needs cider-nrepl on the server.

(Fun fact: tracing a function that the tracing itself calls, like clojure.core/map, used to take the whole server down. neorepl refuses to trace those, and as of orchard 0.48 the server no longer falls over on them anyway.)

Coding agents

0.3 was all about coding agents. The CLI was usable by agents from day one, but now they get a lot more help:

  • neorepl mcp serves neorepl’s features as MCP tools over stdio, for agents that would rather call tools than run commands. With Claude Code it’s a one-liner:

    $ claude mcp add neorepl -- neorepl mcp

    The tools are eval, load, doc, source, where, apropos, macroexpand, reload, test, retest, pst, interrupt, check and ports. That’s fewer than the CLI’s commands on purpose: an agent pays for every tool’s description in its context on every turn, whether it uses the tool or not. Evaluations get cut at 10,000 characters and interrupted after 60 seconds by default, so a stray (range) or an infinite loop can’t eat the agent’s context or its session.

  • A skill teaches agents the workflow I’d like them to follow: after editing a file, check its parens, load it, run its tests, and read the stack trace when something fails. It costs nothing until the agent actually works on Clojure code. doc/agents.md explains how to set it up with Claude Code, Codex, Gemini CLI, Cursor and VS Code.
  • neorepl check --fix repairs unbalanced delimiters the way parinfer’s indent mode would, trusting the indentation over the parens. Mismatched parens are still the most common way for an agent to break a Clojure file, and now fixing them doesn’t take another trip through the model:

    $ neorepl check src/demo/orders.clj
    src/demo/orders.clj:3:1: Unclosed (
    $ neorepl check --fix src/demo/orders.clj
    src/demo/orders.clj:3:1: Unclosed (, repaired from the indentation

The CLI and the MCP tools share named sessions, the one called agent unless told otherwise, which brings me to my favourite addition in 0.4.

Looking over your agent’s shoulder

Stepping into an agent's session

,sessions lists the server’s named sessions, and ,sessions agent steps into the agent’s, with its name in the prompt. Everything the agent left behind is right there: its namespace, its *1, its *e. So when an agent tells you that a test fails with a NullPointerException, you can look at the exception yourself instead of asking it to paste the stack trace. ,sessions - takes you back to your own session.

The lookups take --session too, so neorepl doc --session agent foo looks up foo the way that session sees it, in its namespace and its language, ClojureScript included.

The REPL, round two

0.4 also spent a lot of time on the REPL itself:

  • ,ns switches namespaces, loading the one you name if it isn’t loaded yet, and lists the project’s namespaces when you don’t name one.
  • C-x C-s shows the source of the symbol at the cursor and C-x C-a the vars whose names contain it, next to the C-x C-d for docs that was there from the start. All three come from rebel-readline.
  • ,doc and the arglists that show up as you type cover Java classes, methods and fields too, with their Javadoc as text (that one needs cider-nrepl).
  • ,edit and the stacktrace browser open definitions that live in jars, instead of telling you they can’t.
  • Ctrl-C got a lot more reliable. An interrupt sent right after an evaluation could get lost when the server hadn’t started running it yet, the forms after an interrupted one ran anyway, and whatever an interrupted evaluation printed later could show up in the next one’s output. All fixed.
  • neorepl sends the forms you enter to the server one at a time now, which is a big part of the interrupt fixes, and as a bonus piggieback evaluates all of them, not only the first.
  • On Linux, every evaluation used to wait 40ms on the server’s socket, courtesy of TCP’s delayed ACKs. neorepl turns those off now, on the JVM and on babashka 1.13.227 or newer, and evaluating a file with 100 forms went from 4.5 seconds to about 150ms.
  • Lots of error messages got friendlier: a build tool that isn’t installed, a server that dies while starting, loading a file with unbalanced parens, a named session the server lost when it restarted, and so on.

So much for keeping neorepl small, by the way - the code has about doubled since 0.1. In my defense, it does a lot more than twice as much.

Making the neighbours better, round two

Just like last time, building neorepl turned up problems elsewhere, and a good part of these two weeks went into fixing them at the source:

  • I filed a batch of issues for babashka’s nREPL server: test runs that hung or killed the session, :project? being ignored, a session that never answered again after its client disconnected, a couple of JLine classes neorepl’s line editor needed. Michiel fixed every single one of them within days, and they’re all in babashka 1.13.227. Thanks, Michiel!
  • cider-nrepl 0.64 and orchard 0.48 came out this week with fixes for the problems I ran into: what tests print now comes back with the test results (it used to go to the connection of the session’s previous message, which for a CLI is long gone), the test op no longer goes silent for a namespace that isn’t loaded or runs every test when the one you asked for doesn’t exist, ClojureScript tests run on shadow-cljs, a traced call that throws shows up as such, and Javadoc no longer comes with stray spaces in it. CIDER gets all of that too, which is my favourite kind of side effect. neorepl 0.4.1, out the day after this post, uses cider-nrepl 0.64 when it starts a server.
  • In nREPL, the fix for an interrupted evaluation’s last response going to the wrong connection is merged, and so is TCP_NODELAY on the server’s sockets, which takes 100 evaluations with nREPL’s own client from 4.4 seconds to 90ms on Linux. Both will be in the next nREPL release.
  • The piggieback improvements I mentioned last time are a bit behind schedule. A lot of ClojureScript REPLs run on top of piggieback, so a mistake there has a pretty big blast radius, and evaluating every form of a message is something we never found a good way to do in the past. I’d rather take my time with those than rush them out. neorepl isn’t waiting on them, at least, since it sends the forms one at a time now.

What’s next

0.5 is about more runtimes and better navigation:

  • integration tests against real servers in CI: cider-nrepl and babashka first, then shadow-cljs and piggieback with Node
  • nbb and Basilisp in CI, with their gaps documented
  • cross-references (,refs), on top of cider-nrepl’s fn-refs and fn-deps
  • a full-screen namespace browser
  • a couple of ideas borrowed from clojure-cli.repl: evaluating the form at the cursor, and the last evaluation’s outcome and time in the prompt
  • indenting continuation lines, and a way to pick the vi keymap

Better Windows support, the one item from the 0.1 list that’s still open, is planned for 0.6, together with easier ways to install neorepl.

Try it

$ bbin install io.github.nrepl/neorepl
$ cd my-project
$ neorepl

The CHANGELOG has everything I skipped here, and the README has the details. Please keep the issues and ideas coming!

Keep hacking!

My core.logic fork

ftlm :faster-than-light /memes Joyful ideas are better ideas

search This is your fathers rock. I mean this is the searchbar. Work With Benjamin Hire me Resume CV Claude Code Programming with Claude Code Explore! Get a random piece of Bennis mind! Lisp What does it mean for a language to be beautiful? Documentaries etc. History of cybernetics and related fields FAQ Answers and reasons for everything. Books Like a whetstone is to a sword Posts A list of all posts. Screencasts Dancing

My core.logic fork

A series on making Clojure's maps, vectors and sets relational in core.logic: every relation runs in every direction, and anything it can't decide yet, it waits on visibly instead of guessing. The code and an overview are in the repository README.

What it looks like

Each relation is a Clojure function read as a relation, so any argument can be the unknown one.

Maps. get, assoc, merge, conj and keys, forwards and backwards:

(run* [v] (geto {:a 1 :b 2} :b v))          ;;=> (2)
(run* [k] (geto {:a 1 :b 2} k 2))           ;;=> (:b)          which key holds 2

(run* [m] (assoco m :b 2 {:a 1 :b 2}))
;;=> ({:a 1} {:a 1, :b _0})                 ;; m had no :b, or any :b that assoc overwrote

(run* [m1 m2] (mergeo m1 m2 {:a 1}))
;;=> ([{:a 1} {}] [{} {:a 1}] [{:a _0} {:a 1}])

(run* [q] (fresh [v] (conjo q [:foo v] {:foo 1})))
;;=> ({} {:foo _0})                         ;; the wiki's test case, below

(run* [k] (== {:a 1 k 2} {:a 1 :b 2}))      ;;=> (:b)          unknown keys unify
(run* [ks] (keyso {:a 1 :b 2} ks))          ;;=> (#{:b :a})

(run* [m] (geto m :a 1) (geto m :b 2))
;;=> ((lmap {:b 2, :a 1} | _0))             ;; an open map: these keys, maybe more

Vectors. nth, conj, concat, subvec, with lengths solved exactly:

(run* [i] (ntho [:a :b :a] i :a))           ;;=> (0 2)
(run* [v x] (conjo v x [1 2 3]))            ;;=> ([[1 2] 3])
(run* [s e] (subveco [:a :b :c :d] s e [:b :c]))
;;=> ([1 3])                                ;; where the slice came from

(run* [a b] (concato a b [1 2 3]))
;;=> ([[] [1 2 3]] [[1] [2 3]] [[1 2] [3]] [[1 2 3] []])

(run* [v] (counto v 2) (ntho v 0 :a))       ;;=> ([:a _0])

(run* [x] (fresh [l r] (concato x [1] l) (concato [1] x r) (concato r [2] l)))
;;=> ()                                     ;; |x| + 1 ≠ |x| + 2, no search needed

Sets. Literals with unknown elements, conj, union, difference:

(run* [x y] (== #{x y} #{1 2}))             ;;=> ([1 2] [2 1])
(run* [s] (conjo s 3 #{1 2 3}))             ;;=> (#{1 3 2} #{1 2})
(run* [a] (uniono a #{2 3} #{1 2 3}))       ;;=> (#{1 3} #{1} #{1 3 2} #{1 2})
(run* [x] (subseto #{1 x} #{1 2 3}))        ;;=> (3 2)         x is distinct from 1

(run* [x] (differenceo #{1 2 3} x #{1}))
;;=> (((lset #{3 2} | _0) :- (clojure.core.logic/lackso _0 1)))
;; 2 and 3, plus a rest without 1

(run* [s] (containso s 1) (containso s 2))
;;=> ((lset #{1 2} | _0))

Together. The relations compose across kinds, and paths are relations too:

(def db {:users [{:name "ada" :tags #{:admin}} {:name "bob" :tags #{}}]})

(run* [n] (fresh [us u tags]
            (geto db :users us) (ntho us (lvar) u)
            (geto u :name n) (geto u :tags tags) (containso tags :admin)))
;;=> ("ada")

(run* [p] (get-ino db p "bob"))             ;;=> ((:users 1 :name))

;; the same question as a pattern: at least these keys, at least this element
(run* [n] (fresh [i u] (get-ino db [:users i] u) (matcho u {:name n :tags #{:admin}})))
;;=> ("ada")

Why this is the core.logic thing

A logic language takes its terms from its host. miniKanren's term is the Scheme pair, because Scheme's data is the pair: appendo and membero work so well because a partly known list has a natural shape, a known front and an unbound tail. core.logic brought miniKanren to Clojure, and the pair came along. Clojure's own data came in second class: two maps unified only if they had exactly the same keys, and apart from featurec reading values at known keys, nothing could take a map apart or build one.

But the data is what Clojure is. Literal, immutable maps, vectors and sets, used for everything: "just use maps", data over functions over macros, a program is a pipeline of get, assoc, merge, conj and union. Clojure programmers don't encode a record as an association list or a set as a list with no duplicates; they write {:name "ada"} and #{1 2}.

So a relational Clojure should be relational over that. This series makes it so: get, assoc, merge, nth, concat, union and friends, each as a relation that runs backwards. A query can ask which map an assoc came from, which key path leads to a value, or which two sets have this union, on the same data the rest of the program uses. That's the part of core.logic only Clojure can have, and what miniKanren-on-lists can only encode.

This isn't a new wish. In 2013 David Nolen put it on core.logic's own roadmap. The wiki page Constraints & modes opens: "core.logic unfortunately to this day continues to emphasize operating on sequence-like data structures due to its Prolog & Scheme roots", and asks for "constraint versions of Clojure functions". It sketches an assocc, finds that running it backwards branches, and settles on conjo as the test case:

(run* [q]
  (fresh [v o]
    (== o {:foo 1})
    (conjo q [:foo v] o)))

"So we should see the following results: ({} {:foo _0})." That is exactly what the fork answers. The companion page CLP(Set) asks for sets with unknown elements, (unionc #{:cat x y} #{:dog :bird z} o), and notes that such a union branches over which elements pair up. The fork runs that one too: with o = #{:cat :dog :bird}, x and y are :dog and :bird in either order and z is :cat. (Writing this found a bug: the fork used to let x be :cat too. Fixed.) Both pages are still on the wiki's roadmap, under "Immediate", and were never built. The machinery they sketch is not what this series builds (a defc with mode declarations, set domains like the FD domains), but their test cases are what it answers.

It also sets the bar. A relation that only works in some directions is a function with extra steps, so the series holds every relation to one rule: it answers correctly in every mode, or waits until it can (part 2).

  1. Relational maps: a tour of the API.
  2. No unsupported behaviour: why the design had to be redone, and how unknown keys, lazy checks and suspended goals fit together.
  3. Making it fast: from 33 s to 8 ms, and what was slow.
  4. Vectors: open vectors, lengths solved exactly, and get-in as a query language.
  5. Literature and miniKanren parity: where this sits in the research, and what core.logic still lacks.
  6. Towards relational hyperdimensional computing: the same data structures, encoded as hypervectors.
  7. Sets: maps without values, and what that buys.
  8. Side by side with miniKanren: faster-miniKanren's test suite run on core.logic, the bugs it found, and maps, sets and vectors next to their miniKanren encodings.
  9. A relational Clojure interpreter: evalo for a Clojure subset, with relational maps as environments; what it runs backwards, and where its search is still slow.

Next, maybe: records as types, and ClojureScript. See future work.


Generated by Claude on Benjamin Schwerdtner's behalf.

Date: 2026-10-28 Wed 00:00

Email: Benjamin.Schwerdtner@gmail.com

Mein core.logic-Fork

ftlm :faster-than-light /memes Joyful ideas are better ideas

search This is your fathers rock. I mean this is the searchbar. Work With Benjamin Hire me Resume CV Claude Code Programming with Claude Code Explore! Get a random piece of Bennis mind! Lisp What does it mean for a language to be beautiful? Documentaries etc. History of cybernetics and related fields FAQ Answers and reasons for everything. Books Like a whetstone is to a sword Posts A list of all posts. Screencasts Dancing

Mein core.logic-Fork

Eine Serie darüber, wie Clojures Maps, Vektoren und Sets in core.logic relational werden: Jede Relation läuft in jede Richtung, und was sie noch nicht entscheiden kann, darauf wartet sie sichtbar, statt zu raten. Code und Überblick stehen im README des Repositorys.

Wie es aussieht

Jede Relation ist eine Clojure-Funktion, als Relation gelesen, also kann jedes Argument das unbekannte sein.

Maps. get, assoc, merge, conj und keys, vorwärts und rückwärts:

(run* [v] (geto {:a 1 :b 2} :b v))          ;;=> (2)
(run* [k] (geto {:a 1 :b 2} k 2))           ;;=> (:b)          welcher Key hat 2

(run* [m] (assoco m :b 2 {:a 1 :b 2}))
;;=> ({:a 1} {:a 1, :b _0})                 ;; m hatte kein :b, oder ein :b, das assoc überschrieb

(run* [m1 m2] (mergeo m1 m2 {:a 1}))
;;=> ([{:a 1} {}] [{} {:a 1}] [{:a _0} {:a 1}])

(run* [q] (fresh [v] (conjo q [:foo v] {:foo 1})))
;;=> ({} {:foo _0})                         ;; der Testfall aus dem Wiki, unten

(run* [k] (== {:a 1 k 2} {:a 1 :b 2}))      ;;=> (:b)          unbekannte Keys unifizieren
(run* [ks] (keyso {:a 1 :b 2} ks))          ;;=> (#{:b :a})

(run* [m] (geto m :a 1) (geto m :b 2))
;;=> ((lmap {:b 2, :a 1} | _0))             ;; eine offene Map: diese Keys, vielleicht mehr

Vektoren. nth, conj, concat, subvec, mit exakt gelösten Längen:

(run* [i] (ntho [:a :b :a] i :a))           ;;=> (0 2)
(run* [v x] (conjo v x [1 2 3]))            ;;=> ([[1 2] 3])
(run* [s e] (subveco [:a :b :c :d] s e [:b :c]))
;;=> ([1 3])                                ;; woher der Ausschnitt stammt

(run* [a b] (concato a b [1 2 3]))
;;=> ([[] [1 2 3]] [[1] [2 3]] [[1 2] [3]] [[1 2 3] []])

(run* [v] (counto v 2) (ntho v 0 :a))       ;;=> ([:a _0])

(run* [x] (fresh [l r] (concato x [1] l) (concato [1] x r) (concato r [2] l)))
;;=> ()                                     ;; |x| + 1 ≠ |x| + 2, ohne Suche

Sets. Literale mit unbekannten Elementen, conj, union, difference:

(run* [x y] (== #{x y} #{1 2}))             ;;=> ([1 2] [2 1])
(run* [s] (conjo s 3 #{1 2 3}))             ;;=> (#{1 3 2} #{1 2})
(run* [a] (uniono a #{2 3} #{1 2 3}))       ;;=> (#{1 3} #{1} #{1 3 2} #{1 2})
(run* [x] (subseto #{1 x} #{1 2 3}))        ;;=> (3 2)         x ist verschieden von 1

(run* [x] (differenceo #{1 2 3} x #{1}))
;;=> (((lset #{3 2} | _0) :- (clojure.core.logic/lackso _0 1)))
;; 2 und 3, plus ein Rest ohne 1

(run* [s] (containso s 1) (containso s 2))
;;=> ((lset #{1 2} | _0))

Zusammen. Die Relationen lassen sich über die Arten hinweg kombinieren, und auch Pfade sind Relationen:

(def db {:users [{:name "ada" :tags #{:admin}} {:name "bob" :tags #{}}]})

(run* [n] (fresh [us u tags]
            (geto db :users us) (ntho us (lvar) u)
            (geto u :name n) (geto u :tags tags) (containso tags :admin)))
;;=> ("ada")

(run* [p] (get-ino db p "bob"))             ;;=> ((:users 1 :name))

;; dieselbe Frage als Muster: mindestens diese Keys, mindestens dieses Element
(run* [n] (fresh [i u] (get-ino db [:users i] u) (matcho u {:name n :tags #{:admin}})))
;;=> ("ada")

Warum das die eigentliche Sache von core.logic ist

Eine Logiksprache nimmt ihre Terme von ihrer Wirtssprache. Der Term von miniKanren ist das Scheme-Paar, weil Schemes Daten das Paar sind: appendo und membero funktionieren so gut, weil eine teilweise bekannte Liste eine natürliche Form hat, einen bekannten Anfang und einen ungebundenen Rest. core.logic hat miniKanren nach Clojure gebracht, und das Paar kam mit. Clojures eigene Daten waren Bürger zweiter Klasse: Zwei Maps unifizierten nur, wenn sie genau dieselben Keys hatten, und außer featurec, das Werte an bekannten Keys liest, konnte nichts eine Map zerlegen oder eine bauen.

Aber die Daten sind das, was Clojure ausmacht. Literale, unveränderliche Maps, Vektoren und Sets, für alles benutzt: "just use maps", Daten vor Funktionen vor Makros, ein Programm ist eine Pipeline aus get, assoc, merge, conj und union. Clojure-Leute kodieren einen Datensatz nicht als Assoziationsliste oder ein Set als Liste ohne Duplikate; sie schreiben {:name "ada"} und #{1 2}.

Ein relationales Clojure sollte also über diese Daten relational sein. Diese Serie macht das wahr: get, assoc, merge, nth, concat, union und Verwandte, jeweils als Relation, die rückwärts läuft. Eine Anfrage kann fragen, aus welcher Map ein assoc kam, welcher Key-Pfad zu einem Wert führt oder welche zwei Sets diese Vereinigung haben, auf denselben Daten, die der Rest des Programms benutzt. Das ist der Teil von core.logic, den nur Clojure haben kann, und den miniKanren auf Listen nur kodieren kann.

Der Wunsch ist nicht neu. 2013 hat David Nolen ihn auf die Roadmap von core.logic selbst gesetzt. Die Wiki-Seite Constraints & modes beginnt mit: "core.logic unfortunately to this day continues to emphasize operating on sequence-like data structures due to its Prolog & Scheme roots", und wünscht sich "constraint versions of Clojure functions". Sie skizziert ein assocc, stellt fest, dass es rückwärts verzweigt, und nimmt conjo als Testfall:

(run* [q]
  (fresh [v o]
    (== o {:foo 1})
    (conjo q [:foo v] o)))

"So we should see the following results: ({} {:foo _0})." Genau das antwortet der Fork. Die Begleitseite CLP(Set) wünscht sich Sets mit unbekannten Elementen, (unionc #{:cat x y} #{:dog :bird z} o), und merkt an, dass so eine Vereinigung darüber verzweigt, welche Elemente zusammenfallen. Auch die läuft im Fork: Mit o = #{:cat :dog :bird} sind x und y in beliebiger Reihenfolge :dog und :bird, und z ist :cat. (Beim Schreiben habe ich dabei einen Bug gefunden: Der Fork hat x früher auch :cat sein lassen. Behoben.) Beide Seiten stehen noch auf der Roadmap im Wiki, unter "Immediate", und wurden nie gebaut. Die Maschinerie, die sie skizzieren, ist nicht die, die diese Serie baut (ein defc mit Modus-Deklarationen, Set-Domänen wie die FD-Domänen), aber ihre Testfälle sind die, die sie beantwortet.

Sie legt auch die Messlatte. Eine Relation, die nur in manche Richtungen funktioniert, ist eine Funktion mit Extraschritten. Deshalb hält die Serie jede Relation an eine Regel: Sie antwortet in jedem Modus korrekt, oder sie wartet, bis sie es kann (Teil 2).

  1. Relationale Maps: ein Rundgang durch die API.
  2. Kein nicht unterstütztes Verhalten: warum das Design neu gemacht werden musste, und wie unbekannte Keys, lazy Checks und suspendierte Goals zusammenpassen.
  3. Schnell machen: von 33 s auf 8 ms, und was langsam war.
  4. Vektoren: offene Vektoren, Längen exakt gelöst, und get-in als Abfragesprache.
  5. Literatur und Parität mit miniKanren: wo das in der Forschung steht, und was core.logic noch fehlt.
  6. Auf dem Weg zu relationalem hyperdimensionalem Rechnen: dieselben Datenstrukturen, als Hypervektoren kodiert.
  7. Sets: Maps ohne Werte, und was das bringt.
  8. Seite an Seite mit miniKanren: die Testsuite von faster-miniKanren auf core.logic, die Bugs, die sie gefunden hat, und Maps, Sets und Vektoren neben ihren Kodierungen in miniKanren.
  9. Ein relationaler Clojure-Interpreter: evalo für eine Teilmenge von Clojure, mit relationalen Maps als Umgebung; was er rückwärts kann, und wo seine Suche noch langsam ist.

Als Nächstes vielleicht: Records als Typen, und ClojureScript. Siehe Future Work.


Generiert von Claude im Auftrag von Benjamin Schwerdtner.

Datum: 2026-10-28 Wed 00:00

Email: Benjamin.Schwerdtner@gmail.com

Clojure 1.13.0-alpha11

Clojure 1.13.0-alpha11 is now available! Find download and usage information on the Downloads page.

Fix:

  • CLJ-2990 :req! on a nested map with :missing binds an internal Object

Try it out

Update your deps.edn :deps with:

org.clojure/clojure {:mvn/version "1.13.0-alpha11"}

Start a REPL with the Clojure CLI (any version) with:

clj -Sdeps '{:deps {org.clojure/clojure {:mvn/version "1.13.0-alpha11"}}}'

gasbb: Clojure in Google Sheets

ftlm :faster-than-light /memes Joyful ideas are better ideas

search This is your fathers rock. I mean this is the searchbar. Work With Benjamin Hire me Resume CV Claude Code Programming with Claude Code Explore! Get a random piece of Bennis mind! Lisp What does it mean for a language to be beautiful? Documentaries etc. History of cybernetics and related fields FAQ Answers and reasons for everything. Books Like a whetstone is to a sword Posts A list of all posts. Screencasts Dancing

gasbb: Clojure in Google Sheets

If nbb is a thing, what else could be a thing?

nbb is borkdude's ClojureScript scripting for Node. It is a formula, really: take SCI, the Small Clojure Interpreter, drop it into a host that is already there, and inherit everything the host can do. 1 Node gives you npm. The browser gives you scittle. VS Code gives you Joyride. 2 Same move, different host. (Jacking in nbb and Binaural Beats with Scittle are older posts of mine from that world.)

So you go looking for hosts that have a JavaScript engine and a scripting slot with no Clojure interpreter in it yet. Postgres has one (plv8). Figma has one. 3 And Google Sheets has one: Apps Script, Google's JavaScript, running on V8. 4

That one is now gasbb: Clojure for Google Apps Script.

Clojure in a cell

=CLJ("(->> % (map first) frequencies)", B2:B100)

A range comes in as a vector of rows. What comes out spills back into the sheet: a sequence goes down a column, a map becomes key/value rows.

sheet.png

This is the demo spreadsheet. It is public. File → Make a copy and you have your own, script included.

There is something funny about frequencies in a spreadsheet cell. Spreadsheets are the most successful functional programming environment ever: every cell is a pure function of other cells, and a huge number of people use them without ever hearing the word "pure". Clojure is the same idea for people who did hear the word. They were always cousins. gasbb just introduces them.

Clojure files in the project

Apps Script projects hold two kinds of files: scripts and HTML. 5 No .clj. But HTML files are kept verbatim, and a script can read one back as a string with HtmlService.createHtmlOutputFromFile(name).getContent(). 6

So a file called main.clj is "an HTML file" that happens to contain Clojure. gasbb reads it at runtime and hands it to SCI. require works the same way: util.text is looked up as the file util/text.clj. It is a little bit of a smuggling operation, and I like it.

(ns main
  (:require [gasbb.sheets :as sheets]))

(defn top-scorers []
  (->> (sheets/read-maps "Scores")   ;; header row -> keyword keys
       (filter #(> (:points %) 30))
       (sort-by :points >)
       (map :name)))

Interop is just js/SpreadsheetApp, js/GmailApp, js/UrlFetchApp. Everything Apps Script can do, Clojure can do, once the service's permission is listed in the manifest. 7

The one thing you cannot smuggle: Apps Script only sees functions that are declared in a script file. Menus, triggers and custom functions point at names. So there is a small Code.gs that declares the entry points and forwards each one to Clojure:

function onOpen(e)          { gasbb.run("main.clj", "main/on-open", e); }
function CLJ(code, ...args) { return gasbb.cell(code, args); }

A REPL that forgets everything

REPL sidebar

Rendered locally with the same bundle. In a sheet, every eval is a round trip to Google, so expect more than the milliseconds shown.

There is a REPL in a sidebar. And here comes the twist: every call from the sidebar to the server is a fresh Apps Script execution. All files evaluated again, nothing kept from the last call. 8 Your def from a second ago is gone.

So the sidebar remembers for the server. It keeps the top-level ns, require and def forms you evaluated, and replays them before each new eval, namespace and all. The REPL has amnesia, and the browser is its notebook. Like the guy in Memento with the tattoos.

The catch: the notebook gets re-read, not just remembered. A def with a side effect, say appending a row, runs again on every eval. Keep side effects out of def.

It sounds slow. The interpreter is not the slow part: the SCI context comes up in about 8 milliseconds inside Apps Script, and loading a .clj file plus a required namespace takes about 20. 9 What you feel is everything around it: starting a fresh execution, parsing the ~950 KB bundle, the round trip from the browser. Interpreters are cheap now. Round trips are not.

What it can't do

  • Custom functions are sandboxed: they have to finish within 30 seconds, and they can only call services that don't access personal data. 10 CLJ inherits that.
  • SCI is an interpreter. Don't loop over 100,000 cells one by one, read the range in one go and work on the data. (You should do that in JavaScript too.)
  • No setTimeout in Apps Script, so nothing that needs timers. 11

Do we need this?

Honest question. Claude writes Apps Script JavaScript fluently. Why should anyone want a Clojure bridge in the age of AI code?

Maybe the language matters less now. What still matters is the loop: can you (or your agent) ask the running system something, get an answer in milliseconds, and try again? A REPL into a spreadsheet is that kind of loop. A spreadsheet already is that kind of loop.

Or maybe it is just fun. That is allowed too.

Open questions

  • What is the next host? Postgres (plv8), k6, n8n, Figma, Obsidian, Godot (via GodotJS)… Which one has the most people stuck in a scripting slot they don't love?
  • Is a spreadsheet the best REPL most people will ever use, and should we build programming tools that look more like one?
  • Does an AI agent prefer a REPL to a codebase? My guess is yes, but I don't know yet.

Code, setup guide and the demo: https://github.com/benjamin-asdf/gasbb


Generated by Claude Code on behalf of Benjamin Schwerdtner.

Footnotes:

1

nbb: "Scripting in Clojure on Node.js using SCI", "the same interpreter that powers babashka", https://github.com/babashka/nbb. scittle: "Execute Clojure(Script) directly from browser script tags via SCI", https://github.com/babashka/scittle. SCI lists babashka first among its users: https://github.com/babashka/sci

2

"Joyride is Powered by SCI (Small Clojure Interpreter)." https://github.com/BetterThanTomorrow/joyride

3

plv8: "V8 Engine Javascript Procedural Language add-on for PostgreSQL", https://github.com/plv8/plv8. Figma: "Figma plugins are written using primarily JavaScript and HTML", https://developers.figma.com/docs/plugins/how-plugins-run/. For Apps Script there is prior art that compiles ClojureScript ahead of time, e.g. https://github.com/whitecoop/cljs-apps-script (2016). I found no interpreter you could use live from a cell.

4

"Apps Script is now supported by the V8 runtime that powers Chrome and Node.js." https://developers.google.com/apps-script/guides/v8-runtime. V8, not Node: no npm, no Node APIs. The old Rhino runtime was deprecated on January 31, 2026: https://developers.google.com/apps-script/guides/v8-runtime/migration

5

"A script project has one or more script files which can either be code files (having a .gs extension) or HTML files (a .html extension)." Plus the appsscript.json manifest. https://developers.google.com/apps-script/guides/projects

6

createHtmlOutputFromFile "creates a new HtmlOutput object from a file in the code editor", https://developers.google.com/apps-script/reference/html/html-service; getContent() returns its content, https://developers.google.com/apps-script/reference/html/html-output. Scriptlets are not evaluated on this path. In gasbb's self test inside Apps Script, Clojure source came back unchanged.

7

"Apps Script automatically determines required scopes by scanning the code for function calls." https://developers.google.com/apps-script/concepts/scopes. Calls made from Clojure don't show up in that scan, so gasbb's setup guide lists them in oauthScopes by hand.

8

gasbb's self test checks both halves over repeated runs in Apps Script: a global counter of how often its file was evaluated is 1 every time, and a global variable and a Clojure def set at the end of one run are gone in the next.

9

Measured with gasbb's self test, which runs inside Apps Script through the Apps Script API (scripts/gas.py test in the repo): first eval including SCI context init ~8 ms, loading a .clj file with one required namespace ~20 ms, one CLJ call ~9 ms. One run, on 2026-10-26. Not included: starting the execution, parsing the bundle, the round trip.

10

"A custom function call must return within 30 seconds. If it doesn't, the cell displays #ERROR!" Custom functions "never ask users to authorize access to personal data. Consequently, they can only call services that don't have access to personal data." https://developers.google.com/apps-script/guides/sheets/functions

11

gasbb's self test checks it inside Apps Script: typeof setTimeout is "undefined". Apps Script has Utilities.sleep for waiting, but no timers or event loop to schedule callbacks on.

Date: 2026-10-26 Mon 14:00

Email: Benjamin.Schwerdtner@gmail.com

gasbb: Clojure in Google Sheets

ftlm :faster-than-light /memes Joyful ideas are better ideas

search This is your fathers rock. I mean this is the searchbar. Work With Benjamin Hire me Resume CV Claude Code Programming with Claude Code Explore! Get a random piece of Bennis mind! Lisp What does it mean for a language to be beautiful? Documentaries etc. History of cybernetics and related fields FAQ Answers and reasons for everything. Books Like a whetstone is to a sword Posts A list of all posts. Screencasts Dancing

gasbb: Clojure in Google Sheets

Wenn nbb ein Ding ist, was könnte sonst noch ein Ding sein?

nbb ist borkdudes ClojureScript-Scripting für Node. Eigentlich ist es eine Formel: Man nimmt SCI, den Small Clojure Interpreter, setzt ihn in einen Host, der sowieso schon da ist, und erbt alles, was der Host kann. 1 Node bringt npm mit. Der Browser bringt scittle. VS Code bringt Joyride. 2 Gleicher Zug, anderer Host. (Jacking in nbb und Binaural Beats with Scittle sind ältere Posts von mir aus dieser Ecke.)

Also sucht man Hosts, die eine JavaScript-Engine und einen Scripting-Platz haben, in dem noch kein Clojure-Interpreter steckt. Postgres hat einen (plv8). Figma hat einen. 3 Und Google Sheets hat einen: Apps Script, Googles JavaScript, das auf V8 läuft. 4

Daraus ist jetzt gasbb geworden: Clojure für Google Apps Script.

Clojure in einer Zelle

=CLJ("(->> % (map first) frequencies)", B2:B100)

Ein Bereich kommt als Vektor von Zeilen herein. Was herauskommt, ergießt sich zurück ins Sheet: Eine Sequenz läuft eine Spalte hinunter, eine Map wird zu Schlüssel/Wert-Zeilen.

sheet.png

Das ist das Demo-Spreadsheet. Es ist öffentlich. Datei → Kopie erstellen, und man hat sein eigenes, mitsamt Script.

frequencies in einer Tabellenzelle hat etwas Komisches. Tabellenkalkulationen sind die erfolgreichste funktionale Programmierumgebung aller Zeiten: Jede Zelle ist eine reine Funktion anderer Zellen, und unglaublich viele Menschen benutzen sie, ohne je das Wort "pure" gehört zu haben. Clojure ist dieselbe Idee für Leute, die das Wort doch gehört haben. Die beiden waren schon immer Cousins. gasbb stellt sie nur einander vor.

Clojure-Dateien im Projekt

Apps-Script-Projekte enthalten zwei Sorten Dateien: Scripts und HTML. 5 Kein .clj. Aber HTML-Dateien werden wortgetreu gespeichert, und ein Script kann eine davon mit HtmlService.createHtmlOutputFromFile(name).getContent() als String zurücklesen. 6

Eine Datei namens main.clj ist also "eine HTML-Datei", die zufällig Clojure enthält. gasbb liest sie zur Laufzeit und gibt sie an SCI. require funktioniert genauso: util.text wird als Datei util/text.clj gesucht. Ein kleiner Schmuggel, und ich mag ihn.

(ns main
  (:require [gasbb.sheets :as sheets]))

(defn top-scorers []
  (->> (sheets/read-maps "Scores")   ;; Kopfzeile -> Keyword-Schlüssel
       (filter #(> (:points %) 30))
       (sort-by :points >)
       (map :name)))

Interop ist einfach js/SpreadsheetApp, js/GmailApp, js/UrlFetchApp. Alles, was Apps Script kann, kann Clojure, sobald die Berechtigung des Dienstes im Manifest steht. 7

Das Einzige, was sich nicht schmuggeln lässt: Apps Script sieht nur Funktionen, die in einer Script-Datei deklariert sind. Menüs, Trigger und eigene Tabellenfunktionen zeigen auf Namen. Deshalb gibt es eine kleine Code.gs, die die Einstiegspunkte deklariert und jeden an Clojure weiterreicht:

function onOpen(e)          { gasbb.run("main.clj", "main/on-open", e); }
function CLJ(code, ...args) { return gasbb.cell(code, args); }

Ein REPL, der alles vergisst

REPL sidebar

Lokal mit demselben Bundle gerendert. Im Sheet ist jede Auswertung ein Weg zu Google und zurück, also mehr als die gezeigten Millisekunden.

In einer Seitenleiste gibt es einen REPL. Und jetzt kommt der Twist: Jeder Aufruf von der Seitenleiste zum Server ist eine frische Apps-Script-Ausführung. Alle Dateien neu ausgewertet, nichts bleibt vom letzten Aufruf. 8 Das def von vor einer Sekunde ist weg.

Also erinnert sich die Seitenleiste für den Server. Sie behält die ns-, require- und def-Formen auf oberster Ebene, die man ausgewertet hat, und spielt sie vor jeder neuen Auswertung erneut ab, samt Namespace. Der REPL hat Amnesie, und der Browser ist sein Notizbuch. Wie der Typ in Memento mit den Tattoos.

Der Haken: Das Notizbuch wird nicht nur erinnert, sondern jedes Mal neu gelesen. Ein def mit Nebenwirkung, etwa einer angehängten Zeile, läuft bei jeder Auswertung erneut. Nebenwirkungen gehören nicht in ein def.

Klingt langsam. Der Interpreter ist aber nicht der langsame Teil: Der SCI-Kontext steht in Apps Script nach etwa 8 Millisekunden, und eine .clj-Datei samt einem per require geladenen Namespace braucht etwa 20. 9 Was man spürt, ist alles drumherum: eine frische Ausführung starten, das ~950 KB große Bundle parsen, der Weg vom Browser und zurück. Interpreter sind heute billig. Round Trips nicht.

Was es nicht kann

  • Eigene Tabellenfunktionen laufen in einer Sandbox: Sie müssen innerhalb von 30 Sekunden fertig sein und dürfen nur Dienste aufrufen, die nicht auf persönliche Daten zugreifen. 10 CLJ erbt das.
  • SCI ist ein Interpreter. Nicht 100.000 Zellen einzeln durchlaufen, sondern den Bereich in einem Rutsch lesen und mit den Daten arbeiten. (Das sollte man in JavaScript auch so machen.)
  • In Apps Script gibt es kein setTimeout, also nichts, was Timer braucht. 11

Braucht man das?

Ehrliche Frage. Claude schreibt Apps-Script-JavaScript flüssig. Warum sollte jemand im Zeitalter von KI-Code eine Clojure-Brücke wollen?

Vielleicht zählt die Sprache jetzt weniger. Was weiter zählt, ist die Schleife: Kann man (oder der eigene Agent) das laufende System etwas fragen, in Millisekunden eine Antwort bekommen und es nochmal versuchen? Ein REPL in ein Spreadsheet ist so eine Schleife. Ein Spreadsheet ist schon so eine Schleife.

Oder es macht einfach Spaß. Das ist auch erlaubt.

Offene Fragen

  • Was ist der nächste Host? Postgres (plv8), k6, n8n, Figma, Obsidian, Godot (über GodotJS)… Wo stecken die meisten Leute in einem Scripting-Platz, den sie nicht mögen?
  • Ist ein Spreadsheet der beste REPL, den die meisten Menschen je benutzen werden, und sollten Programmierwerkzeuge mehr wie eins aussehen?
  • Ist einem KI-Agenten ein REPL lieber als eine Codebasis? Ich vermute ja, aber ich weiß es noch nicht.

Code, Setup-Anleitung und die Demo: https://github.com/benjamin-asdf/gasbb


Generiert von Claude Code im Auftrag von Benjamin Schwerdtner.

Fußnoten:

1

nbb: "Scripting in Clojure on Node.js using SCI", laut README "the same interpreter that powers babashka", https://github.com/babashka/nbb. scittle: "Execute Clojure(Script) directly from browser script tags via SCI", https://github.com/babashka/scittle. SCI nennt babashka als ersten Nutzer: https://github.com/babashka/sci

2

"Joyride is Powered by SCI (Small Clojure Interpreter)." https://github.com/BetterThanTomorrow/joyride

3

plv8: "V8 Engine Javascript Procedural Language add-on for PostgreSQL", https://github.com/plv8/plv8. Figma: "Figma plugins are written using primarily JavaScript and HTML", https://developers.figma.com/docs/plugins/how-plugins-run/. Für Apps Script gibt es Vorarbeiten, die ClojureScript vorab kompilieren, z. B. https://github.com/whitecoop/cljs-apps-script (2016). Einen Interpreter, den man live aus einer Zelle benutzen kann, habe ich nicht gefunden.

4

"Apps Script is now supported by the V8 runtime that powers Chrome and Node.js." https://developers.google.com/apps-script/guides/v8-runtime. V8, nicht Node: kein npm, keine Node-APIs. Die alte Rhino-Runtime ist seit dem 31. Januar 2026 abgekündigt: https://developers.google.com/apps-script/guides/v8-runtime/migration

5

"A script project has one or more script files which can either be code files (having a .gs extension) or HTML files (a .html extension)." Dazu das Manifest appsscript.json. https://developers.google.com/apps-script/guides/projects

6

createHtmlOutputFromFile "creates a new HtmlOutput object from a file in the code editor", https://developers.google.com/apps-script/reference/html/html-service; getContent() liefert dessen Inhalt, https://developers.google.com/apps-script/reference/html/html-output. Scriptlets werden auf diesem Weg nicht ausgewertet. Im Selbsttest von gasbb in Apps Script kam Clojure-Quelltext unverändert zurück.

7

"Apps Script automatically determines required scopes by scanning the code for function calls." https://developers.google.com/apps-script/concepts/scopes. Aufrufe aus Clojure tauchen in diesem Scan nicht auf, deshalb trägt die Setup-Anleitung von gasbb sie von Hand in oauthScopes ein.

8

Der Selbsttest von gasbb prüft beide Hälften über wiederholte Läufe in Apps Script: Ein globaler Zähler, wie oft seine Datei ausgewertet wurde, steht jedes Mal auf 1, und eine globale Variable und ein Clojure-def vom Ende eines Laufs sind im nächsten weg.

9

Gemessen mit dem Selbsttest von gasbb, der über die Apps Script API in Apps Script läuft (scripts/gas.py test im Repo): erstes Auswerten inklusive Aufbau des SCI-Kontexts ~8 ms, eine .clj-Datei mit einem geladenen Namespace ~20 ms, ein CLJ-Aufruf ~9 ms. Ein Lauf, am 26.10.2026. Nicht enthalten: Start der Ausführung, Parsen des Bundles, der Weg vom Browser und zurück.

10

"A custom function call must return within 30 seconds. If it doesn't, the cell displays #ERROR!" Eigene Funktionen "never ask users to authorize access to personal data. Consequently, they can only call services that don't have access to personal data." https://developers.google.com/apps-script/guides/sheets/functions

11

Der Selbsttest von gasbb prüft das in Apps Script: typeof setTimeout ist "undefined". Zum Warten gibt es Utilities.sleep, aber keine Timer und keine Event-Loop, auf der man Callbacks planen könnte.

Datum: 2026-10-26 Mon 14:00

Email: Benjamin.Schwerdtner@gmail.com

Func Prog Conf 2026

Func Prog Conf 2026 logo projected onto an empty stage

Last Wednesday The Wednesday before last (fine blogging takes time to cook, you know), I attended Func Prog Conf here in Stockholm. It was my first time at the conference, having missed the inaugural instantiation last year somehow. In fact, I would have missed this year as well had not Arne texted to ask if I was going. I played it cool, "Maybe. Because I totally knew about this all along and I just wasn't sure if I could fit it into my awesome life with all the totally rad stuff that I'm always doing." Having maintained my street cred, I ran to my search engine of choice and tried to figure out what the hell he was talking about. 😅

It turns out that Func Prog Conf is a small one day event in Stockholm, just five talks and 180 people. It's organised by the great people over at Func Prog Sweden, who run a meetup group with an interesting twist: every other meetup is online, so they're able to have speakers from all over Europe (and occasionally further afield). Their YouTube channel is definitely worth a look. They are quite ecumenical, with talks from the hardcore type enthusiasts to the Lisp hippies with their laid-back approach to the serious engineers who are down with OTP (yeah you know me). I'd definitely recommend checking out their next meetup, whether in person if you happen to be in Stockholm, or streaming live on YouTube if you happen to be in a compatible timezone or a shifted sleep schedule or high on life or whatever.

Read moreShow less

Anyway, back to the conference. It was held at a really cool venue in Slussen called Kägelbanan, high up on the cliff overlooking the Baltic Sea. It's part of the Södra Teatern complex, which has a really nice brunch on Sunday mornings. Definitely worth a visit in the summer, as much for the view as the food itself.

Sorry, I just realised that I went on a tangent again when I really really intended to talk about the conference itself. 🤦🏼

Oh wait, there's just one more thing. 😅

Just one more thing

The story of the conference actually starts the day before, at least for me. You see, our local Clojure group, sthlm.clj, had organised a Func Prog Conf Prep on the previous evening, which I only found out about because Arne mentioned that he would also be there (with customary humility, he didn't mention that he was giving a talk at the meetup, of course). The meetup was scheduled to start at 16:00, and I was just a tiny bit late due to a frenetic work schedule (every manager knows that the pleasure of taking a day off is accompanied by the pain of having to shift all of the work you would have done on that day to other days). When I walked in a few minutes after 16:00, there was already a talk underway. I slunk into a seat in the back row, eyes downcast as I radiated shame.

Safely ensconced in my seat, I finally looked up at the speaker, and got a lovely shock as I realised it was none other than Christoph Neumann, Clojure developer advocate and all around fine fellow. It turned out that Christoph was doing a run-through off the talk he would be presenting at the conference the next day, a fact to which I was not privy, due to me not having actually read the speaker lineup for the conference.

After he finished, I went over to say hi, and right around the same time, Arne rolled in. Hugs were dispensed, conversation flowed, and the food arrived. As we were chowing down and chatting, Arne said something about his talk, and when I asked for clarification, it turned out that he was giving a talk at this very meetup, a fact to which I was not privy, due to me not having actually read the speaker lineup for the meetup.

If one were feeling particularly generous, one could describe my approach to life as being in the moment; whereas a more ungenerous interpretation would go more along the lines of wtf is wrong with you Josh do you actually read bro?

I throw myself upon the mercy of the readership to adopt a view more akin to the generous one. 🙏

Anyway, as I said, much milling about was occurring, and I made another astute observation:

A man wearing a shirt that says "we do it with rich comments"

Christoph was wearing a Calva t-shirt with a wicked awesome Clojure in-joke! Readers of the Clojure posts in this blog are likely already familiar with the Rich comment, but for those who aren't:

"These comments are rich because they provide rich detail about the development process and because they were written by a person named Rich."

There's a good explanation of Rich comments on the Between Two Parens blog, which I encourage y'all to read.

19:00 arrived, and with it, the first of the evening's two talks: Markus Holmström's: Cellsify – A take on Spreadsheets you didn't expect and now you need it in your life. This talk was as funny as it was mind expanding (spoiler alert: it's spreadsheets all the way down), and the second it was over, I DM'd Markus on Clojurians Slack to invite him on defn.

Next up was (a.k.a Arne Brasseur a.k.a. plexus), with Datastar – how to use it with Clojure and some of the architectural choices. I had read some about Datastar, but had gotten it confused with some other frontend thingy that used Datalog that was maybe developed by Roman Liutikov but maybe not since I can't find it in my memory or in my search engine... send paramedics please!

But like, yeah, Datastar. Seems pretty cool. But don't take my word for it; watch the talk! 😉

Even before the applause died down, I was on my feet, sprinting out of the Agical office and through Gamla Stan to catch my train.

I shall spare you the details of the rest of my evening (don't worry, I caught my train) and finally get around to talking about the conference itself.

The conference itself

Fast forward then to the next morning. The doors were set to open at 08:00, so I got myself to Slussen in good time, then walked up to the venue and arrived at about 07:50. Being a good Swede, I lurked just outside so I could be exactly punctual—not a minute early, not a minute late. Unfortunately, I rolled a 5 on my lurk check, and was detected by one of the Kägelbanan staff, who was out on the terrace in front of the venue having a crafty cig. Like, a real cigarette, with tobacco and all, not one of those new-fangled e-cigarettes that all the damn kids are puffing away on these days (get off my lawn, you damn kids!). That was surprising, but super cool, because you rarely see people going full James Dean in Stockholm anymore.

Even more surprisingly, she struck up a conversation with me. 🤯

You see, Stockholmers rarely so much as make eye contact with strangers, and never actually say so much as "hej" to them (unless out hiking, in which case they are downright friendly; or neighbours, in which case they might give a curt nod, a "hej" if they're feeling friendly, or even a "hej hej" if they're high on ecstasy or whatever). I guess this woman was primed for interacting with a bunch of foreigners (or maybe high on ecstasy, I dunno), but converse she did. I stumbled through the conversation in my barely functional Swedish, thinking I was getting away with it until she asked "Pratar du engelska på jobbet eller?" I hung my head and admitted that yes, I did in fact speak English at work, and yes, I was embarrassed that I hadn't learned Swedish in my 16 years here. Luckily, she wasn't the SD type, so she let me off the hook with the standard "Men svenskar alskar att prata engelska, så bli det tufft att lära sig svenska." I nodded, still appropriately ashamed of my laziness.

Anyway, we chatted away for a few minutes, then she told me it was actually OK to go in a couple minutes early (and by this, I mean a literal couple, since it was 07:58). I thanked her effusively, then headed inside to find my badge. I said hi to Magnus, one of the organisers of both the Func Prog Sweden group and the conference itself, and he told me that I wasn't in fact the first attendee to arrive—apparently someone else had been there since just after 07:00!

Grabbing my badge, I headed into the mingle area and armed myself with a coffee and a nice breakfast roll. I spotted Felix Alm setting up the Multiply booth, and walked over to chat. A few more people started arriving, and I had a really nice chat with a guy named Mauricio about Scala and Clojure and Lisp and programming philosophy and so on. He helped me distribute some defn and Pitch stickers whilst we talked, then finally it was nearing 09:00, when the conference officially kicked off.

People mingling

I made a beeline for the auditorium so I could get the perfect seat (third row, right side, aisle), and luxuriated in the sounds of silence as I checked out the setup. After a minute or two, Magnus walked out on stage and gave his opening remarks one last look as people started to file into the auditorium and find their seats. I snapped a quick picture, then whipped out my trusty Babashka-branded notebook and settled back in my seat, ready to be entertained and educated by the stellar lineup of speakers that had been assembled for the day.

A man stands on stage, studying his phone as he prepares his remarks

Christoffer Ekeroth from Functional Software (a Swedish recruitment agency specialising in functional programming roles) appeared in the wings, surveying the crowd. When he felt the moment was right, he strode onto stage and with a gesture, invoked the spotlights. He and Magnus assumed b-boy stances (OK, maybe that was just in my mind) and a hush fell on the crowd. They kicked off the event by introducing themselves, giving a brief history of the conference, and introducing the sponsors.

Two men on stage

After brief remarks from each sponsor, it was time for the opening keynote: "Transforming Software Evolution: Lessons from 10 Years of Clojure in Production", by Adam Tornhill.

Transforming Software Evolution

Adam Tornhill prepares to start his talk

I learned two things in the first 30 seconds of Adam's talk:

  1. He is a Clojurist (which I learned from his title slide)
  2. He is a Swede

The reasons for the first one were twofold: I hadn't actually looked at the talk lineup before the conference (as mentioned above), and another super embarrassing reason that shall be revealed presently.

The reason for the second one was far simpler: I had never seen Adam speak or heard him interviewed on a podcast (the former is now solved, whilst the latter is pending).

So anyway, in that first 30 seconds, Adam introduced himself, then started walking us through his path to Clojure. Apparently he got started pretty early!

A toddler holding a copy of the "Practical Common Lisp" book

It turned out that this precocious hacker wasn't Adam, but his son. Shortly after this picture was taken, his son started tearing pages out of the book, which Adam noticed were from the section on Common Lisp's type system. "So he was way ahead of me," Adam noted, drawing a chuckle from the crowd and a few mock scowls from some of the hardcore Haskellites in the crowd. Ah, 'twas all in good fun, 'twasn't it? A friendly trolling amongst friends, nothing more. 😉

Adam presents a slide labelled "My Road To Functional Programming"

There were many parallels to my own introduction to programming, which started out early in life on a primitive but beloved machine on which we used primitive but beloved programming languages (assembly, C, and Pascal for Adam; Motorola 6502 assembly, C, and BASIC for me), then moving on to slightly less primitive but slightly less beloved languages like C++ and Java, before reading Paul Graham's "Beating the Averages" essay (which I won't link to here because Paul Graham is an odious person) and seeing the Light of LISP. This is where our paths diverged a little bit, as Adam went for Common Lisp, even writing a book entitled Lisp for the Web (which you can access on the web for any price between 0 - 20—your choice!), whereas I went for the elegant weapon for a more civilised age that is Scheme.

I loved me some Scheme, but knew that I wasn't likely to be able to use it at work (even though one of my colleagues was writing a Lisp implementation in C++ as a hobby project at the time), so Lisp remained merely a source of personal pleasure for me. Until 2009, that is, when I encountered Stuart Halloway's "Programming Clojure". Lisp on the JVM sounded like a thing I might be able to sneak in, especially given the fact that my company was in the midst of a massive migration from Perl (yay!) to Java (less yay, but still OK). But then I moved from Japan to Ireland, trading a Perl-but-soon-to-be-Java team for a team doing operations for a big C++ search engine with many lovely Bash and Perl scripts, so poor Clojure had to wait.

It was somewhere around 2012 that I first used Clojure at work, building an end-to-end testing system for our product. Judging from his timeline, it seems that Adam was getting into Erlang and C# around the same time. In late 2013, I took my first real Clojure job, building the backend of a rock'n'roll trivia game in Clojure on AWS. Sometime in the next year, I too picked up C#, writing the network stack for our game's new frontend, which was built on Unity. I actually used Clojure there too (unbeknownst to my colleagues), thanks to Tims Gardner and Ramsey Nasser's amazing Arcadia, which was a port of ClojureCLR to Unity. 🤯

Thanks to Acadia, I was able to sit in a REPL and use the powers of interactive programming to prototype a network stack, one evaluated S-expression at a time. Oh happy times! That is, until I had to take my pristine Clojure code and translate it into C#. 😢

Whilst I was messing around with Unity, Adam was writing a book: Your Code as a Crime Scene. As a brief aside (surprise surprise) I just love the description of the book:

Jack the Ripper and legacy codebases have more in common than you’d think. Inspired by forensic psychology methods, you can apply strategies to identify problems in your existing code, assess refactoring direction, and understand how your team influences the software architecture. With its unique blend of criminal psychology and code analysis, Your Code as a Crime Scene arms you with the techniques you need to take on any codebase, no matter what programming language you use.

I have a copy of the book, but have only read the introduction. Which is why I didn't realise that Adam was a Clojurist. 😬

But back in 2015, I didn't have a copy of the book, because I only heard about it somewhere in the Long 2020. What I did have a copy of in 2015 was Joe Armstrong's Programming Erlang. Why I had this is somewhat unclear to me. My friend Ryan had dabbled in Erlang back in the winter of 2000-2001, and gushed about lightweight processes and live reloading and supervisors and stuff, but when I looked at Erlang back then, I had never seen an immutable, functional language before, so it looked like complete gibberish to me (this was before the infamous Programming Languages course I took in university that made me a first-class Lisp Hater).

But taking a page out of Adam's book (in a metaphorical fashion, don't worry), I can examine the dusty crime scene that was 2015 and uncover some clues:

  1. The games company I was working for was rapidly running out of money
  2. One of the angel investors bought out the rest of the investors and laid off all of the engineering staff but me
  3. That guy was a real d-bag
  4. I had grown to hate my job
  5. I wanted a new job
  6. Recruiters could sense this
  7. One of the companies the recruiters aimed me at was this Swedish fintech that had the world's largest Erlang system and employed all the Erlang developers in Stockholm and the surrounding environs
  8. I figured I should learn Erlang if I was interviewing for a job as an Erlang developer
  9. The way I learn a new programming language is to read the book first, then work through some of the more interesting exercises in the book, then write a somewhat non-trivial system using that language (these days, I write a Scheme implementation; back then, I guess I wrote some sort of webservice that did something something?)

From this evidence, I can surmise that I bought the book because I wanted to learn Erlang. And I did get an offer from that fintech, but I decided instead to join a streaming TV company to do Scala webservices (I actually did know Scala, having worked with it professionally for about a year at the same company where I built that Clojure end-to-end testing thingy). Of course, when I started at that company, I was dropped into a team that did low-level video transcoding in C and Python. But then we got this potential deal with a big TV company to do video on demand, and to win the deal, we had to demonstrate that we could transcode and stream arbitrary titles from their catalogue, and we had a week to do it. So I reached for the most obvious tool: Pixie. 😅

You see, this was many years before Babashka would make it possible to write Clojure shell scripts that started up immediately, and many many years before babashka.ffi, and I needed to call into libffmpeg from my scripts (I actually ended up abandoning that and just shelling out to the ffmpeg command-line tool).

The team I was part of consisted of me, two hardcore Finnish consultants who worked remotely from Helsinki, and this Swedish guy who worked together with me in the Stockholm office. At that point, I had been working for the company for maybe three months, and I had learned a little bit about video streaming (I had worked with my Swedish colleague on a DASH server in Golang) and maybe a little bit more about video transcoding, but I wouldn't really say I knew what I was doing.

Somehow, we made enough progress that we were very close by Wednesday morning. But not quite there. And the company we were trying to win the deal with was based in Qatar. And the Qatari working week runs from Sunday to Thursday. Which meant that we had until Thursday morning to have a working demo of our system. 😱

So my colleague and I loaded up on coffee and sat down at my desk and started what would turn out to be a nearly 15 hour pairing session. I would hack some Pixie, then he would take the keyboard to tweak our DASH server, then we'd switch again for more Pixie, then we'd get stuck and ping the Finns on our IRC channel (I think it was still IRC back then, but maybe we had Slack?), and they'd give us some ffmpeg incantation that was waaaaay over my head and just grazing the top of my Swedish colleague's head, and then back to the DASH server, and so on and so forth.

Around midnight, we successfully ingested, transcoded, and served our first video asset (I seem to remember it being a Champions League match). By 1 AM, we were scratching our heads wondering why the second asset we tried didn't work. But by 2 AM, we had fixed that, and by 3 AM, we had ingested five assets in a row, chosen completely at random (by shuffle-ing a list of asset IDs, natch). By 4 AM, I was in bed, trying to convince my caffeine-addled brain to stop projecting ffmpeg commands on the inside of my eyelids. And by 10 AM, I was somehow back at my desk, giving our demo system one last poke before we sent it over to the client.

And it worked! And we won the deal, which wouldn't have been possible without Lisp. So naturally, when it was time to build the production version of the system, we chose to do it in... Elixir. 😂

It turns out that my Swedish colleague had an irrational fear of Lisp, and an undying love of Ruby, and the things I loved most about Clojure were functional programming, immutable data structures, and interactive development, so we compromised on a language that had all of those things for me and looked a lot like Ruby for him.

It was the best decision we ever made. Elixir was absolutely perfect for the task. We had a set of processes that scanned the catalogue for new assets, then fired events to another set of processes that downloaded the assets, then another set that transcoded downloaded assets, and of course a DASH server. We built the thing interactively in Emacs with the amazing Alchemist.el, and after the first week of development to get a skeletal cluster up, we didn't restart the system more than once or twice in the three months it took to finish the thing.

Try that with C++, I dare you. 😉

But anyway, back to 2026, and this functional programming conference that I'm allegedly writing about (though there is a reason I took the Elixir detour, I promise). 😅

Like I said, there are a lot of parallels in Adam's and my roads to Clojure, but there's a decisive split circa 2025, when Adam Go(es) agentic and Rusty, and I very much don't. As I mentioned, I had done some Go at the video streaming company, with very mixed results. On the one hand, writing a service that listened to UDP multicast from a satellite downlink station and served it up on a TCP socket was like 50 lines of code. 🏆 On the other hand, writing a DASH server that read some JSON and then generated an MPD manifest was like a hundred million lines of code. 😭

And Rust? Well, if I was still doing much systems programming, it seems like a reasonable choice, and there's some really interesting stuff in the language, but my few forays into the community surrounding the language have left me with an incredibly bad taste in my mouth.

And agents? Well, any Clojurians reading this probably know how I feel about LLMs (a scourge upon humanity, etc). But more on this anon.

What Adam is doing in this talk is explaining why Clojure was a great choice for building CodeScene, the system he built for analysing the health of big codebases. And one of the key characteristics of Clojure is that it's a functional programming language. And a functional programming language is...

Adam presents a slide labelled "What's Functional Programming"

Well, what exactly? As Fogus and chouser quip on this slide, it's not an easy question to answer:

"If you ask 100 programmers for their definition, you'll likely receive 100 different answers."

But Adam actually answers the question quite succinctly on his previous slide:

A slide labelled "Functional programming benchmark"

Less code, more features, more fun... in Erlang, that is. In Clojure, it's more fn, proving once again its superior expressiveness. 😉

Interestingly enough, Adam is trained as a psychologist. So of course he had to give us an IQ test, showing us a set of patterns with one missing, and asking the audience to shout out the number corresponding to the missing pattern.

It started out with a reasonably straightforward one:

A slide labelled "Let's do an IQ Test"

Got it? Many people in the audience did too, yelling out "8" with gusto.

But then, the friendly IQ test turned into this:

A slide labelled "...and a slightly harder test"

Adam presents a slide labelled "...and a slightly harder test"

If you struggled with this one, you are far from alone. Only one brave voice in the audience called out the answer (which is 5).

Having humbled us with the IQ test, Adam made a recommendation: "Do not turn your code into an IQ test." Sage advice indeed!

He then explained the goal of Code Health, the central concept behind the CodeScene platform he had developed in Clojure: to bring visibility to code and code quality:

Adam presents a slide labelled "Code Health: bring visibility to code and code quality"

At a high level, what CodeScene does is:

  1. Parse the source
  2. Identify code smells
    • Module / class level smells: e.g. low cohesion, God Classes
    • Function level smells: e.g. copy-pasted logic, God Functions, primitive obsession
    • Implementation smells: e.g. deep nested logic, complex conditionals
  3. Score, aggregate, and categorise:
    • 💚 Healthy code with low risk defect
    • ⚠️ Increased maintenance efforts
    • 🛑 Unhealthy code with significant issues and defect risks

This allows CodeScene to build really cool visualisations to identify areas of the code where technical debt is lurking. Of course, when you have a massive codebase like the Linux kernel, with 21.4 million lines of code, contributed by over 15,000 authors...

Adam presents a slide labelled "The Need For Concurrency: Analysing massive codebases"

...you can't exactly just

(map calculate-code-health-of files)

Of course, with Clojure, you can just add a character to turn the serial into the parallel:

(pmap calculate-code-health-of files)

According to Adam, "That single character gave me a 10x speedup. It was the most efficient character I've ever written!"

But other than the magic of the letter p, what does Adam look for in a programming language?

Adam presents a slide labelled "What do I look for in a programming language?"

When he first chose Clojure for CodeScene back in 2015, it was:

  • ✅ Productivity: fast to change the code
  • ✅ Interactive: easy to iterate
  • ✅ Fun: "I'll spend a lot of time with this code..."

And in 2026, with his agents:

  • ❌ Productivity: fast to change the code
  • ✅ Interactive: easy fast to iterate
  • ❌ Fun: "I'll spend a lot of time with this code..."
  • ✅ Validation: easy to inspect and review
  • ✅ AI-optimised: program close to the domain

Clojure still wins.

Anytime I write the word "AI", I of course need to give the 🤮 disclaimer.

Here's a fun code health problem for us:

Adam presents a slide labelled "Meet a Code Health problem: Deep Nested Complexity"

Looks like Ryu needs to learn recursion. 😂

One way to think about programming is that you have two models:

  1. A situation model, which is the programmer's mental representation of the problem domain
  2. The corresponding system model, which is how the programmer models the domain in software

Adam presents a slide showing a diagram of situation model and system model

The situation model is used to express knowledge, which is encoded in the system model, then data is input and the outcome is observed by the programmer, allowing them to update their situation model (this is where learning occurs). The bigger the gap between these two, the more cognitive load is placed on the programmer, thus increasing the chance of defects in the system.

The programmer's goal then becomes to close the gap between the two models, and one way Adam proposes to do this is to build domain-specific languages, such as:

(defn costs-by-file
  "Given a sequence of task durations (e.g from Jira),
   what are the development costs per file?
   Development costs are expressed in work days, wd."
  [duration-by-task]
  (->> duration-by-task
       (map (partial assign-to-files file-name->ticket))
       (filter passes-minimum-thresholds?)
       (reduce by-file-name)

And you can take this a step further with an embedded DSL, using the great and terrible power of macros!

Gandalf saying "I would use this macro from a desire to do good..."

You could define a macro along these lines:

(defmacro enable-code-health-suppressions-for
  [& suppressions]
  (->> suppressions
       (map (fn [s]
              `(defmethod apply-directive-to ~s [parsed-code#]
                 (apply-shared-logic parsed-code#)
                 (remove-warning ~s parsed-code#))))))

And then write code like this:

(enable-code-health-suppressions-for
  :complex-method
  :bumpy-road
  :deep-nested-complexity)

which would expand at compile time to this:

(defmethod apply-directive-to :complex-method [parsed-code]
  (apply-shared-logic parsed-code)
  (remove-warning :complex-method parsed-code))

(defmethod apply-directive-to :bumpy-road [parsed-code]
  (apply-shared-logic parsed-code)
  (remove-warning :bumpy-road parsed-code))

(defmethod apply-directive-to :deep-nested-complexity [parsed-code]
  (apply-shared-logic parsed-code)
  (remove-warning :deep-nested-complexity parsed-code))

So it looks like macros can be used for good after all... for a time. But continuing down this road leads to Common Lisp, and Common Lisp leads to:

Cover of the book "Let Over Lambda"

Adam's claim is that judicious use of macros can move the programmer from instructing a machine (which is slow) to expressing a domain (which is powerful).

Adam presents a slide showing a diagram of moving from instructing a machine to expressing a domain

And all jokes aside, Clojurists do use macros, but as a tool of last resort. The Clojure philosophy is something like this:

  1. Use data whenever possible
  2. When you can't use data, use a function
  3. When you can't use a function, say a little prayer and use a macro

Whereas the Common Lisp philosophy is more like this:

  1. Use macros everywhere, especially reader macros, so you can write code that doesn't even look like Lisp anymore and is so powerful that no mere mortal can comprehend it

Having made a series of compelling points about how to keep complexity under control with the power of functional programming in Clojure and enticed us to try out CodeScene (I'm starting the free trial as soon as I have a few free days in my calendar to point it at my company's fairly large Java, TypeScript / JavaScript + React, and .NET codebases to see where be the dragons), Adam summarises why we should all be using Clojure in 2026 (🤮 disclaimer on the first point):

Adam presents a slide labelled "Why Clojure?"

This was a fantastic talk and a great way to kick off the conference! I would recommend that everyone watches the talk as soon as it goes up (and I'll try to remember to update this blog post with the link to the recording when that happens), but in the meantime, you can also check out Adam's slides.

After the thunderous applause died down and Adam answered a question from the audience (which I didn't note down, but I remember as being an actual question, not the infamous Tech Bro "Question") and folks started filing out, I took up my lurker's position near the stage, ready to intercept Adam before he could escape the auditorium.

I thanked him for his talk, admitted that I had only read the intro to his book, and invited him to come on defn and talk about all this awesome stuff. We're trying to find a date as I write this, so hopefully it works out and you'll have something to feast your ears on in a few weeks (and I'll try to remember to update this blog post with the link to the episode when that happens). 🎉

After a coffee break, the next talk began, and it was of a very different character to Adam's: Eric Torreborre's "Beyond the Expression Problem".

Beyond the Expression Problem

I have to admit that this one was tough going for me, so whilst the Haskellers and Scalars in the crowd nodded knowingly throughout, I took copious notes, which helps my brain decomplect.

Eric's talk was basically about the holy grail of compositional programming:

Eric presents a slide labelled "Compositional programming"

If you have no idea what that slide means, fear not! Eric explained it. And his explanation made sense to me at the time, but looking back at my notes as I write this, it seems that my absorption of the topic was limited to the absorption of ink into my notebook, and did not indeed penetrate my thick skull. Ah well, I can at least share a couple of slides that I enjoyed!

Eric presents a slide labelled "Modularity is great!"

Legos! And the Millennium Falcon! And the Millennium Falcon made out of Legos! And yes, that does in fact cost basically 10,000 Swedish Crowns, which as of this writing is about 900 Euros or 1000 US Dollars. 🤯

So modularity is good, but

Eric presents a slide labelled "In practice", showing a game of Tetris about to be lost

But not to worry

Eric presents a slide labelled "Modularity is difficult, but there's hope!"

Because the type system can save us!

Eric presents a slide labelled "In practice"

"That's a very simple lambda calculus in a way," quoth Eric, as my head exploded.

But then there's a twist: one has to actually implement this, which Eric illustrates as follows:

Eric presents a slide labelled "In practice"

Eric's face falls. "It sounds like the whole type system is useless." 😭

But there was a happy ending, and Eric concluded that it may be possible to implement this in a new programming language that he is working on together with some researchers.

Despite the type magic going a bit over my head, the talk was extremely engaging, and Eric got a few good questions at the end.

Mulling it over afterwards, I thought back to his explanation of the expression problem, which is that it's difficult to extend types and functions at the same time. Unless I'm missing something, it feels like Clojure makes this pretty easy: we have multimethods, which cover the most common cases, and protocols, which "avoid the 'expression problem' by allowing independent extension of the set of types, protocols, and implementations of protocols on types".

I was reminded of a Haskell team that I managed a few years back. They had a huge amount of fun, constructing elegant cathedrals of types and typeclasses and higher-kinded types to solve problems that they had created for themselves. Meanwhile, our stakeholders were becoming increasingly desperate for us to like actually ship the software so we could actually like solve an important problem for our customers.

I don't claim this is representative of static type enthusiasts working in industry, only that a significant portion of my interaction with the statically typed programming language communities (both at work and conferences) tends toward the esoteric and academic. And no shade, that's very interesting. I often enjoy Haskell and Scala talks at conferences (even when my comprehension level is usually 25% at best), and definitely like the vibe of a group of people excitedly expanding their minds in communion with each other.

But here's my problem: I've ended up in one too many conversations over the years where I magnanimously offer that statically typed and dynamically typed languages are equally valid, offer different tradeoffs, and ultimately tend toward the shape of the programmer's mind, rather than an inherent superiority of one over the other, only to be told confidently "you don't get it" (almost certainly true, but I feel that validates rather than invalidates my assertion), or "you probably just haven't worked on large enough systems to have seen the limitations of dynamic typing" (I always have to bite my tongue not to be That Guy™ and mention a certain e-commerce website that I worked on for a few years, in a dynamically typed language). I don't go around trying to convince people that Clojure is better than their favourite statically typed programming language, I just make the statement that Clojure is better for me than their favourite statically typed programming language.

Anyway, after this feast for the mind came a feast for the body, in the form of lunch. I had some kind of vegan salad thingy with falafel and other stuff, which was as delicious as a deftly placed defmethod, especially with a side of conversation with Arne and Christoph, which continued whilst relaxing on some comfortable sofas on a mezzanine overlooking the stage, which I must shamefully admit caused us to pay only cursory attention to Adam Warski's "The Scala 3 Features for Agentic Development, Today and Tomorrow" (🤮 disclaimer on the middle part of the title). But such is the cost of fellowship, and it's one I'll gladly pay!

The next talk, however, drew me downstairs and into the auditorium like a moth to a flame: Giuseppe Castagna and Guillaume Duboc presenting "All Set: Bringing Static Types to Elixir". See, I told you there was a point to my previous rhapsody on Elixir!

The iron fist of José Valim

Giuseppe being introduced by Christoffer Ekeroth

I absolutely loved this talk! Of course, I am extremely partial to Elixir. In fact, if every Lisp disappeared from the world tomorrow, I would shed a single tear, and then, like a Phoenix rising from the ashes, be reborn as an Elixir programmer.

The talk started with Giuseppe showing some code with type annotations in a comment above each function, but they were different from the type of type annotations (sorry, I promise that wasn't intentional) I'm used to seeing. In fact, these type annotations read more like pseudocode, which, like, I can get behind.

Giuseppe admitted that all wasn't rainbows and unicorns, however:

Giuseppe presents a slide labelled "Precise typing of pattern matching"

I mean... I can't help but deploy this meme:

Charlie Day gestures wildly in front of a board with papers connected by a web of red string

But wait, Giuseppe is about to turn my frown upside down!

"The hard part is to derive algorithms to decide if a type s is a subtype of a type t. But don't worry, we know how to do that, so you don't have to do it yourself."

Hurrah!

I don't remember the exact context for this quote, but it amused me enough for me to jot it down in my notebook:

"That seems silly, but is actually very smart."

The coolest part of the talk was when Giuseppe revealed Elixir creator José Valim's reaction to their work. "Very cool," quoth he, "but here are my requirements for adding it to the compiler":

  1. You don't have to annotate the entire codebase in order to start seeing benefits (gradual typing!)
  2. Types don't modify the runtime semantics of your program

The rest of the talk was about how they implemented their type system within José's constraints, and it was a very clever solution indeed, as it ended up not requiring any annotations (I think), but rather using type inference to make the type checking as strict as it could be whilst not pushing the programmer into that annoying situation where you know your code is correct, but end up arguing with the compiler for hours before giving up and just throwing an Any or void * or whatever at it just to shut it up.

They also measured the type checking time as a proportion of the overall compilation time, with the goal of keeping it under 10% (I think—I didn't write down the actual target, for some reason).

So yeah, great talk!

And finally, it was time for Christoph Neumann to deploy some game theory to help us navigate the challenges of industrial programming.

Infinite Coding

The iron fist of José Valim

Giuseppe being introduced by Christoffer Ekeroth

I absolutely loved this talk! Of course, I am extremely partial to Elixir. In fact, if every Lisp disappeared from the world tomorrow, I would shed a single tear, and then, like a Phoenix rising from the ashes, be reborn as an Elixir programmer.

The talk started with Giuseppe showing some code with type annotations in a comment above each function, but they were different from the type of type annotations (sorry, I promise that wasn't intentional) I'm used to seeing. In fact, these type annotations read more like pseudocode, which, like, I can get behind.

Giuseppe admitted that all wasn't rainbows and unicorns, however:

Giuseppe presents a slide labelled "Precise typing of pattern matching"

I mean... I can't help but deploy this meme:

Charlie Day gestures wildly in front of a board with papers connected by a web of red string

But wait, Giuseppe is about to turn my frown upside down!

"The hard part is to derive algorithms to decide if a type s is a subtype of a type t. But don't worry, we know how to do that, so you don't have to do it yourself."

Hurrah!

I don't remember the exact context for this quote, but it amused me enough for me to jot it down in my notebook:

"That seems silly, but is actually very smart."

The coolest part of the talk was when Giuseppe revealed Elixir creator José Valim's reaction to their work. "Very cool," quoth he, "but here are my requirements for adding it to the compiler":

  1. You don't have to annotate the entire codebase in order to start seeing benefits (gradual typing!)
  2. Types don't modify the runtime semantics of your program

The rest of the talk was about how they implemented their type system within José's constraints, and it was a very clever solution indeed, as it ended up not requiring any annotations (I think), but rather using type inference to make the type checking as strict as it could be whilst not pushing the programmer into that annoying situation where you know your code is correct, but end up arguing with the compiler for hours before giving up and just throwing an Any or void * or whatever at it just to shut it up.

They also measured the type checking time as a proportion of the overall compilation time, with the goal of keeping it under 10% (I think—I didn't write down the actual target, for some reason).

So yeah, great talk!

And finally, it was time for Christoph Neumann to deploy some game theory to help us navigate the challenges of industrial programming.

Christoph presents a slide labelled "Finite and Infinite Games"

Before revealing the meaning behind the titillating title of his talk, however, Christoph poses what I can only assume is a rhetorical question:

Christoph presents a slide labelled "What is your favorite programming language?"

But then, he posed a tougher question: if you could not use your favourite programming language, what language would you use instead?

This question was actually posed in the 2025 State of Clojure survey, and the top five answers were mostly unsurprising:

  1. Java - 11.40% 🤷🏼
  2. Python - 10.09% 🐍
  3. Quit programming - 9.89% 😲
  4. Elixir - 9.08% 💜💜💜
  5. TypeScript - 7.06% 😬

But wait, what's going on at #3 there?

Apparently, nearly 1 in 10 Clojure programmers would actually quit programming altogether if they could not use Clojure! 🤯

I'm one of the 9 out of 100 that would use Elixir (unless I could sneak Scittle or Babashka or Jank or Clojerl or Cherry or Squint past the Ministry of Love), as noted above, but I totally get the sentiment.

Most of the Clojurists in the audience had already seen this, either when the State of Clojure results were published back in February, or the day before during Christoph's dry run at the sthlm.clj meetup, but minds were clearly blown in the audience, and many a furrowed brow was spotted as Haskellers and Scalars contemplated life without their powerful types and slow compilers (sorry! I jest out of love for my fellow Functionalistas) and Elixirians despaired at the thought of letting it crash only to find it's unsupervised.

Having gotten the audience's attention, Christoph made a statement that I think everyone in the room would have agreed with two years ago: making software is hard. He then issued an update which I hope is uncontroversial amongst the people in the audience and readership of this blog, even in 2026: making excellent software is hard. If this is the case, a deeper question emerges:

Christoph presents a slide labelled "What makes software excellent?"

Christoph ended his talk early by just giving us the answer: functional programming! (and monads), drawing a hearty laugh from the crowd. But of course he wasn't actually done, and like the good Clojurian he is, he pointed out that there's no way to answer a question such as "what makes software excellent?" without defining the word "excellent":

Christoph presents a slide labelled "excellent = surpass, be superior"

And now it's officially a Clojure talk!

OK, but now that we know what "excellent" means, we must ask ourselves what our software should be excellent at. Christoph offers some common options:

  • Performance
  • Speed of creation
  • Reliability
  • Feature set
  • Usability
  • Maintainability
  • Correctness
  • Code expressiveness
  • Token spend 🤮

But then there's a twist! Excellence at any of these matters not a whit if you're not solving real-world problems!

Christoph presents a slide labelled "Real-World Problems"

And to solve problems, we must make choices. But there's a paradox here: choosing an option reduces our options! 😱

There is a way to manage this paradox, though. If we separate our choices, make them truly independent of each other, a choice in one space only affects that part of the solution, leaving options open elsewhere. And this, my dear friends, is why we decomplect. If you're not a Clojurist, "decomplect" means something like "to take apart a complex thing by unbraiding it into its constituent pieces, each of which can now be used independently". I actually couldn't find a good reference for the definition, so if anyone reading this can point me at one, that would be wonderful. Rich Hickey does however clearly define the word "complect" in his epic Simple Made Easy talk: "to interleave, entwine, or braid". (By the way, there's an amazing repo on GitHub called talk-transcripts, which has full transcripts for loads of talks by such luminaries as Rich Hickey, Alan Kay, David Nolan, Guy Steele, Evan Czaplicki, etc.)

OK, back to Christoph. Having elucidated the paradox of choice and offering us a way out, Christoph asserts that changeability (and of course he includes a definition slide: the ability to make the changes that we want or need) is the central factor of longevity of an excellent codebase. Remembering the paradox of choice, one of the best design questions we can ask ourselves is "what choices can we defer?" I can't remember where I read this, but someone said that what we should be doing is not making choices at the last possible moment, but rather at the last responsible moment, and I think that's what Christoph is getting at here.

And now another definition. Optionality: preservation of choices. We want to preserve optionality for as long as possible responsible, allowing us to decide later when we have more information. Having now presented the concepts of changeability and optionality, Christoph tells us what they have to offer us:

  • Changeability avoids rigidity, but
  • Optionality avoids choices

And optionality is more important to us as programmers than changeability.

Finally, Christoph reveals why his talk is titled "Infinite Coding". In a book from 1986 called Finite and Infinite Games, the philosopher James P. Carse proposed that

all human behavior can be categorized into two types of play. Finite games, such as chess or politics, have defined boundaries, fixed rules, and clear winners, played with the sole purpose of bringing the game to an end. In contrast, infinite games, such as culture, love, and life itself, have evolving rules and open boundaries; they are played for the purpose of continuing play and welcoming new participants.

Christoph thinks this is a very useful theoretical construct for talking about programming, and he simplifies the concept for us:

  • The goal of a finite game is to win.
  • The goal of an infinite game is to keep playing indefinitely.

Finite games have boundaries, such as time, rules, and players, whereas in order to keep playing an infinite game (which is the goal), the rules must change, and play is in fact threatened by boundaries of the sort imposed by finite games.

So what does this have to do with software?

Christoph presents a slide labelled "Working on software is indefinite, therefore an infinite game"

For systems that solve real-world problems, which tend to evolve over time, we want to be able to evolve our software for as long as the problem exists; in other words, we want to keep playing indefinitely. When our software becomes so rigid that we cannot change it anymore, the game ends, and we lose.

So what kinds of things end the infinite game of software?

  • Constraint deadlock: you modelled the problem one way, necessarily accepting a set of constraints, but now you realise you got the model wrong, and those constraints make it difficult to change the model.
  • Intractability: the code becomes difficult to understand, and therefore difficult to safely change.
  • Too many bugs: software that is unreliable is often worse than no software at all.
  • Burnout: you get so worn out by trying to work on a system that you just can't anymore.
  • Inability to bring in a new player: if you can't onboard a new developer, you're stuck carrying the load yourself.

Like all infinite games, as we play the game of software, we encounter many finite games along the way, such as:

  • The optimisation game: we specialise code, thus sacrificing optionality for the sake of performance.
  • The feature game: we add feature after feature after feature, which requires making choice after choice after choice.
  • The metrics game: story points, velocity, lines of code, tokens (🤮); line go up good!
  • The framework game: the One Framework makes so many promises, but we are all of us ultimately betrayed and fall into the walking death that is constraint deadlock.
  • The programming language game: the One Language makes so many promises, but demands of us fealty to its theory of computation.
  • The hiring game: as our system grows, we want to bring in new players, but it is hard to find the right players with the right skills and the right potential at the right time. And the cost of bringing in the wrong player can be incredibly high.
  • The pain-shifting game: we sacrifice tomorrow to win today (AKA incur "technical debt"—a phrase I don't love, for reasons that Eric Normand elucidates so eloquently in his newsletter), or we suffer and management wins.

As any programmer will attest, playing finite games is necessary. However, finite games may harm the infinite game, and it's possible to win our way into failure. What we want to do is to win the finite games we play, but choose to play only the ones that don't threaten the infinite game.

To keep the infinite game going, we want to:

  • Avoid limits (insofar as we can),
  • isolate change (when we must change),
  • and avoid the need to change in the first place.

To avoid the need to change, we need to identify the things that don't change, which is of course easier said than done. There is hope, however, as Christoph points us at a book by Morgan Housel called Same As Ever, and then offers some examples from the programming game:

  • Data: data formats may change, but yesterday's weather data is forever. It was 14 degrees and mostly cloudy in Stockholm at 16:19 at the Södermalm weather station, and so it shall remain until the death of the universe and therefore time itself.
  • Generic solutions (but not specific ones): map, filter, and reduce are eternal, regardless of which functions we're mapping over, filtering by, and reducing with.
  • Parts of the software (but not the whole code base): if you haven't seen Clojure's code retention diagram (from Rich Hickey's History of Clojure paper), which shows when code in the Clojure codebase was introduced, and how long it was retained, check it out below.
  • The loose (but not the tight): the more loosely coupled the parts of our system are, the more easily we can handle change, growth, and movement.
  • Separate things (but not combined things): or, to translate this into Clojurese, simple things, not complected things.

Graph labelled "Clojure codebase - Introduction and retention of code"

Christoph now poses the question of what keeps the infinite game going? And then of course offers some answers:

  • Keep data central and behavior separate and independent.
  • Keep data open, not closed (e.g. include in your schemas only the fields you need to do your job, and allow other fields to be there).
  • Apply consistency when it's useful but not for its own sake (Rich Hickey refers to this as "pouring concrete"; don't do it until you're sure you have the right shape).
  • Avoid unnecessary dependencies, which come with their own sets of constraints that someone else has chosen.
  • Maintain locality: constraints applied to an independent part of the system do not limit our choices in other parts of the system.
  • Avoid unnecessary specification: what is specified cannot be easily changed.
  • Avoid boilerplate (if we remember Adam's talk, he offered one solution: embedded DSLs).
  • Make current code do more instead of adding lots of new code.

But here comes the real world again:

Christoph presents a slide labelled "The real world threatens play"

To play any game, we need a good strategy, and Christoph proposes one here:

  • Avoid limits: opt into constraints really than accepting them unthinkingly.
  • Isolate change: by decomplecting, we get a set of simple pieces that we can compose as we see fit.
  • Avoid change: by identifying the unchanging, we can build with lasting pieces.

By applying this strategy skilfully, we can maximise both changeability and optionality.

Christoph presents a slide labelled "Maximize changeability and optionality"

And now for Christoph's mic drop moment:

We are human. Playing must be fun and worthwhile!

🫳🏻🎤💥

As you may have gathered from the number of words I expended writing about this talk, I thoroughly enjoyed it. My notes runneth to five pages, and verily did I consult Christoph's slides to make sense of them.

With that, the conference was over... except it wasn't!

The aftermath

Onstage from left to right: Giuseppe Castagna, Guillaume Duboc, Eric Torreborre, Adam Warski, Christoph Neumann, and Christoffer Ekeroth

The organisers gathered most of the speakers onstage for a panel discussion, which was lively and fun, with some disagreement expressed cordially. I wish I had taken notes, because I don't remember many of the questions (one was something like "what is the future of functional programming?" and I'm sure there was an "AI all the things?" 🤮 one in there somewhere) or answers, but I do remember one of the panellists saying something about mutability and me getting so excited that I literally pointed at Christoph, willing him to mention transients in transducers, and getting even more excited when he asked for the mic, then less excited when he said something awesome but not as awesome as transients in transducers. 😂

Finally, Christoffer thanked the speakers, shooed them off the stage, and gave some closing words. Amongst those closing words were words hyping up some upcoming conferences, and amongst those were the EuroClojure 2027 reboot (May 19-21 in Prague), and Arne's own HeartConf, the expansion of the legendary Heart of Clojure conference (August 18-19 in Mechelen, Belgium).

Christoffer in front of a slide displaying the lineage of HeartConf

I'll definitely be at HeartConf (I've already got several ideas brewing, just waiting for that sweet sweet CFP to open)—whether that's an enticement or deterrent I can't say—so hope to see you there!

With that, Christoffer thanked the sponsors for making the conference possible, the speakers for making the conference contentful, the organisers for making the conference, and the audience for making the conference awesome. He all but promised that there will be a Func Prog Conf 2027, and after basking in a huge round of applause, released us to go mingle. Which we did. My social batteries were running low, but I did have just enough juice to grab some juice of the barley (big thanks to the organisers for making sure non-alcoholic beer was available!) and making one last circumnavigation of the mingle zone, saying my thank yous and goodbyes and exchanging LinkedIn profiles and email addresses and Clojurians Slack usernames.

Arne was also feeling the need to recharge, so the two of us shook hands with Christoph and Magnus on our way out the door, then strolled through Södermalm to the place where Arne was staying, taking in the scenery (the clouds had parted and the sky was blue, though rapidly turning into dusk because it was after 17:00 in Stockholm in October) and enjoying some relaxed conversation which was not about Trump or AI (the rules of engagement at the Belgian Clojure meetup in Leuven, if you ever happen to be in the neighbourhood), then finally parted ways, he turning rightwards and I trying to orient myself in order to locate the nearest tunnelbana station.

I had done a pretty good job of keeping my phone in my pocket (except for photos and networking) and my attention in the moment all day, so I resisted the urge to whip out Organic Maps, and instead ducked into a restaurant to actually ask a human being for directions. The human was most obliging, and pointed me "rakt fram" down Folkungagatan, and I enjoyed a leisurely 20 minute stroll through the heart of Söder, the lights coming on as the sky darkened, people going about their business, bicycles cruising past; in short, the city breathing.

What a lovely end to a lovely day.

Biff 2.0.4: LLM skills

I've just released Biff v2.0.4. This release has a hodge-podge of tweaks and fixes based on my own usage. There is also one big addition: a set of LLM skills. From the starter project's README:

  • biff-spec-review: reads spec/ and generates a plan to implement it.
  • biff-spec-dev: implements the plan generated by biff-spec-dev. Calls biff-code-review afterward.
  • biff-code-review: reviews recent code changes against a list of Biff-specific coding conventions. Invoked by biff-spec-dev automatically, or you can run it manually.
  • biff-code-quality: instructions the agent to run clojure -M:run code-quality and fix any test/lint failures.
  • biff-dev: instructions for using the REPL. Invoked by agents automatically when they write Clojure code.

After upgrading your biff.tasks dependency to 2.0.4, these skills will be synced to your local project whenever you run clj -M:run dev.

The most important skill here is biff-code-review. It contains a list of rules for writing code that conforms to Biff conventions. The rules are the result of my own painstaking review of reams of crappy LLM-generated Biff code. From testing them out on a fresh project, I'm fairly pleased with the result. The code I've gotten is definitely still a little weird in places, but it's crossed the "good enough" threshold.

Read moreShow less

My general flow for making Biff apps is now:

Sometimes I'll use a fancy model to review specs/code and a dumber model to do the implementation.

I had some trouble figuring out a structure for getting LLMs to actually follow my rules. I've had success with this flow:

  1. I try to phrase each rule so it's very explicit/unambiguous/black and white. Either the rule is violated or it's not.

  2. biff-code-review instructs the LLM to copy a checklist for all the rules into your project. The LLM goes through your code and adds a bullet point (with filename and line numbers) to the checklist file whenever it finds a violation. After the code review, any checklist items without violations are checked off.

  3. The LLM then goes through the checklist and applies fixes.

I structured this in a way so that if needed you can do a human-in-the-loop thing: you can edit the checklist before step 3 starts to add any violations the LLM may have missed. During step 3, the LLM doesn't edit the checklist file, so you can manually mark off/not mark off items and then redo step 3.

editmd 0.2.0

This update adds change tracking. When your agents change a file you’re working on, their changes are automatically highlighted as they appear. It looks like this:

Two agents edit a plan open in editmd, each highlighted in its own color

Each agent's changes are highlighted in its own colour. A best effort attempt is made to label the agent, otherwise it gets a generic label, either way you can edit labels in the legend in the corner. Highlights stay with the agent's text while you keep working and are not saved to the file.

Also in this release is a workaround for a small WebKit bug so that the caret is drawn at the height of the text.

release (link) / #agent-tools #coding-agents #developer-tools #editmd #markdown #release #ui

Personal agents need a new kind of harness

I’ve been working on building a personal agent that is both useful and secure and have come to the conclusion that it needs a new kind of harness, specifically one without a shell. A personal agent comes with the lethal trifecta by default — in order to be useful, it needs access to your data and the internet, which is full of untrusted content and ways to exfiltrate information. Right now personal agent security, such as it is, relies on hopes and prayers that LLMs will follow your requests to please not get pwned. This is wishful thinking and not a reasonable or responsible security posture, in my opinion.

Traditional security practices break down with agents, because agents are not like people. When you grant a person access to a system, you are trusting their judgment, and a person's judgment is a property of who they are, which is why identity-based authorization mostly works for people. A person who reads a phishing attempt is still the same person with the same decision-making capacity as before they read the message.

Agents are fundamentally different. They have no such judgment or decision-making capacity of their own. They act based on instructions they are given, and anything that enters an agent's context can become part of its instructions. The identity of an agent remains stable over time, but whoever is effectively in control of it does not necessarily. After an agent reads a stranger's email, that stranger is partly directing the agent. This is why identity cannot be the basis of authority for agents. An agent's authority is a function of whatever is in its current context, and what tools it has access to at a given moment.

Enforcing this kind of session-based authority requires the harness to mediate every read and every send. It must be the exclusive broker and monitor of every LLM call — everything that enters the session context has to pass through the harness in a tamper-proof way so that it can track what information the session holds, and every action has to pass through it, so that it can tell what an action does before it executes it. A secure harness is effectively a reference monitor (see §3.4, p. 16) — always invoked, tamper-proof, and small enough to verify. The problem is that this is impossible if the agent has a shell. So secure agents cannot have a shell.

The problem with guardrails

Coding agents, like Claude code and codex, are extremely useful because they have broad access to powerful tools and relevant data at the same time. They have access to a shell on your computer, which means they can effectively do anything, and their approach to security is to grant access to everything then try to add guardrails. Things like execution sandboxes, approval prompts, and classifiers. There are many problems with this approach, though. The main one being that it doesn't work. Or at least not all of the time, and as we all know, in security 99% correct is a failing grade.

Prompting for approval leads to fatigue. Anthropic found that people were accepting 97% of approval prompts, which meant after only 50 prompts they were catching just 5% of dangerous commands. To be fair this is partly because their approval prompts are unhelpful. For example this:

An inscrutable approval prompt, not helpful

is supposed to be the version of my job that no longer requires reading code. That is an inscrutable wall of mangled bash commands. There is no universe where a person can tell whether that is safe to run or not at a glance. Sure you can manually scrutinize it, linting and trying to follow the control flow with your eyeballs, but that is almost certainly slower than just manually doing whatever it is that Claude is asking you for permission to do in the first place.

Classifiers are models judging models, which are useful but also leaky. Anthropic's own "Claude containment" doc notes that auto mode blocks "roughly 0.4% of benign commands", at the cost of letting ~17% of overeager actions through. Johann Rehberger of Embrace The Red got malicious code execution past auto mode with up to 80% success with some pretty basic techniques. Notably, the first step is getting claude to decide not to use a tool and to use curl instead. This is why your agent cannot have a shell.

It also goes to show why you cannot trust the frontier labs to self-police. Anthropic's own commissioned test showed 0% success in 720 attempts to hijack auto mode. Their response to Rehberger's report was that auto mode is a best-effort classifier, not a security boundary. IMO it's quite disingenuous to market 0% attack success and then call an attack that succeeds 80% of the time out of scope, but I digress. In their response they say that OS level isolation and network egress control are the real boundaries, which as we'll see, also don't work.

The answer isn't a better sandbox

The standard answer to this is "just run it in a sandbox". The first problem is that if your agent has access to a shell inside a sandbox, information you are trying to keep inside will get out.

Just run claude in a sandbox

To be clear, you definitely need a sandbox, but it solves a different category of problems. Sandboxes limit what an agent can reach, but don't track what information the agent has access to. They are necessary but not sufficient.

Two days after Cowork launched, PromptArmor got it to upload a user's files to the attacker's Anthropic account through Anthropic's own Files API, using a key planted in a file. The exfiltration used also used curl from a shell. The request went to api.anthropic.com, which was on the allowlist because the agent needs it. Anthropic's own account of the incident said "The sandbox worked perfectly, and yet the data was exfiltrated." It's also worth noting that Rehberger had reported this same bug to claude.ai earlier and his report was, as ever, ignored. EchoLeak, found by Aim Labs, got data out of Microsoft 365 Copilot through an image URL on a Teams domain that Copilot's content security policy trusted.

If you want your agent to be useful you simply have to give it access to some powerful tools and even if you only allow the bare minimum, those channels will leak.

An agent's authority depends on what it has seen so far

For a person, permissions can be identity based, because your judgment remains your own, at least to a first approximation over the short term. But agents don't have any judgment or taste of their own, they follow instructions and relentlessly pursue whatever objectives they are optimized for. What an agent can safely do depends on what's in its current context.

Most agents today run on what capability-security researchers call "ambient authority", i.e. authority derived from the identity of the program rather than from the request it's acting on. This leads to the classic Confused Deputy from Norm Hardy's 1988 paper, in which a compiler allowed to write to its own directory overwrote the billing file kept there, because a user handed it that filename as its output. This is effectively the same class of bug as prompt injection. It is far too easy for outsiders to inject instructions into an agent's context today, tricking the agents into using your authority to carry out their requests. The fix from this line of research is to derive authority from the request rather than the agent.

The idea that what you've read limits what you may do is also very old. Kenneth Biba's 1977 integrity model (starts p. 13) includes a low-water-mark policy, where a subject's integrity drops to the lowest level of anything it has read. Meta's Agents Rule of Two is a similar idea applied to agents, saying an agent may hold at most two of the lethal trifecta's properties within a single session, and needs supervision once it has all three. These are both precedent for making authority a property of the session, not of the agent.

The shell itself is the problem

To work out an agent's authority from what its session has seen, the harness has to know two things about every action: what it reads and what it sends. A shell makes both of these inscrutable to the harness. cat, curl, git push, rm -rf, a DNS lookup, or a file write to a synced folder all look the same on the surface (a string handed to bash). Even if you restrict the commands a shell may run, many commands can both read and send, e.g. fetching a URL can exfiltrate data via query params. And there are countless ways a shell can move data from disk to network without ever passing through the model's context, so watching the context isn't sufficient. Mediating these calls has to happen beneath the model, in the harness itself.

In principle, the harness could police every read and send a shell does, but it would have to trace every file, process, and socket each command touches and propagate labels through all of them. This is effectively building an information-flow operating system under the agent, which researchers have done, many times (see Asbestos, HiStar, and Flume). In practice, at that granularity the label creep cripples the usability of the system almost immediately, and the decisions it surfaces are too low-level to be meaningfully approvable anyway, like "process 4121 is trying to connect to 104.18.x.x". This is the inscrutable approval prompt problem again, just at an even lower level.

The fundamental problem is that a harness with a shell is trying to answer "what does this command actually do?" before it runs, and Rice's theorem proved in the 1950s that for arbitrary programs that question is undecidable.

Rehberger's attack is a good example showing how difficult it is to secure a tool as generic and powerful as a shell. The fatal command auto mode approved was a short Python decoder he had tricked Claude into writing itself, after refusing to run the binary decoder he supplied. Superficially, this is harmless. It was only a problem because it ran from inside an archive it had just unzipped, where he had planted a struct.py that shadowed the standard library's, so Claude running import base64 inadvertently ran the malicious code. In some runs, auto mode even blocked Claude's attempt to kill the malware after Claude noticed it. Every step seemed reasonable and passed the auto mode classifier on its own, but that's the thing — most shell commands do.

What a shell command does depends not only on its contents but also the current state of the filesystem and environment it runs in, which an attacker can largely control. Even with perfect knowledge of that state, the question of whether a given command is safe to run or not is undecidable in general. The best a harness can do is over-approximate, which in the context of a shell means assuming every command might read anything and send anywhere. You could restrict a shell to a decidable subset, like a fixed set of commands with checked arguments, but that is effectively rebuilding typed tools with worse ergonomics.

So a harness enforcing session-based authority through a shell just cannot do it automatically. All that's left is asking the model to please be careful, or asking you for permission a thousand times a day.

A secure agent needs a different substrate

Since the shell is the problem, the agent cannot have one. But that means that in order to do anything, it needs access to tools, and the harness has to be the only way it can access those tools. So far these are the requirements that fall out of all this:

  1. The harness must mediate every action. The agent can’t have a shell or side channels or generic ways of accomplishing the things the tools do, like using curl to bypass a web fetch tool. The only way for an agent to read or act has to be via tool calls that the harness mediates, because the harness can only enforce rules on what passes through it.

  2. Every tool must declare its effects. A tool needs to declare whether it reads, writes, sends data out, or acts in the world, and what its output contains. A tool that doesn't follow this contract cannot be trusted to run.

  3. Labels follow the data and the agent can only accumulate them. Anything private is marked as such, anything an outsider could have written is marked as untrusted. The system also must not launder labels through summaries or copying or through time by persisting untrusted content and reading it back later as private.

  4. Policy is small, deterministic code outside the model. For example once a session holds both private and untrusted data, network egress must become structurally impossible without human approval, not just frowned upon.

  5. Only the data's owner can change a label, and only through trusted paths. In decentralized information control flow systems there has to be some source of authority, one way is to have trusted principals with the ability to declassify their own data. In this case, tool calls awaiting approval can go through when I approve them. This approval has to happen on a trusted channel that no agent can spoof.

These requirements would make the harness more secure, but also close to useless, because in practice untrusted content comes fast and furiously into clean sessions, and under these rules that would make it very hard for agents to accomplish anything meaningful. So there's another requirement:

  1. Untrusted content can inform data but never control. There has to be a narrow, checkable way to get information out of untrusted content into an agent session without contaminating its context, i.e. letting it steer the agent. Simon Willison describes one way to do that in his dual LLM proposal with a quarantined model that reads untrusted content and passes back only references or checkable answers. Another more formalized way is described in Google's CaMeL paper, a privileged model whose control flow comes only from the user's request, with untrusted data entering only as tracked values. Microsoft also has an interesting solution, described in their FIDES paper, which tracks confidentiality and integrity labels and hides untrusted values from the model entirely, and is actually available as experimental middleware in Microsoft's Agent Framework.

What's missing is a substrate that incorporates all of these ideas into a single runtime upon which the kinds of personal agents people actually want to run could use.

Where would this leave us?

A system designed like this is basically trading risk for attention. Useful agents often do genuinely need all three legs of the lethal trifecta, which means you'd have to accept the risks that come with that in order for them to be fully autonomous. The alternative is to designate yourself as the sole trusted principal in the system and have every privilege escalation or potentially risky action require your approval. This is very hard to do in a way that doesn't lead to approval fatigue. For starters the questions have to be narrow, clear, and specific, not asking me to judge whether a wall of bash is safe to run or not.

There are also many other downsides to this kind of design. Keeping track of private and untrusted data quickly leads to label creep, and it's hard to design a system where everything isn't just immediately flagged as contaminated. Typed and sanitized answers can still be wrong. The system also can only act through tools, which limits its capabilities and requires purpose-built tools for every new desired capability.

I think coding agents are tremendously useful, but coding agents need a shell because running arbitrary commands in shell is how software is made — compiling code, running tests, managing version control are all essential. But a repo and its dependencies is a much smaller thing to trust an agent with than my inbox, my calendar, and my contact list.

Way back in 1972 James Anderson's planning study for the US Air Force identified the central threat to shared computers as the malicious user, i.e. someone with legitimate access trying to subvert the system from inside. This is a very difficult threat model to defend against. This problem mostly went away with personal computers, so for the last forty years we've effectively sidestepped one of the hardest problems in securing computers. The simplest defence against potentially malicious other users is to just not have any. But now personal agents have brought back this threat, since agents run on our devices with elevated privileges, and because anyone who can send me an email is now, effectively, a user of my agent. The defences the industry built for that threat — mediating access, labelling data, declassification by trusted principals — are fifty years old and well understood. They're just not incorporated into the agents we are now trusting with our entire digital lives. To get them back I think we need a new kind of harness. I don't know if it's possible to build something that incorporates all of this and is still useful, but I'm not ready to give up yet.

post / #agent-tools #ai-agents #ai-safety #coding-agents #lethal-trifecta #llms #personal-agents #security

Clojars and October 2026 Project Updates

Our October report includes an update on the Clojars maintenance and ‘on-call" support work undertaken by Daniel Compton and Toby Crawley during the last 6 months. In addition, Ambrose Bonnaire-Seargent and Cvetomir Dimov share the most recent updates on their projects.

Clojars maintenance and support overview
Malli: Ambrose Bonnaire-Seargent
SciCloj Documentation and Plotting Libraries: Cvetomir Dimov

Clojars maintenance and support overview

Clojurists Together funds Toby Crawley and Daniel Compton to co-maintain Clojars. Their work includes responding to operational issues and community support requests, maintaining the service and its infrastructure, applying security updates, improving publishing and administration tools, and keeping hosting reliable and cost-effective. You can follow this work in the Clojars changelog and in our project updates. For help with Clojars, visit the #clojars channel on Clojurians Slack or open an issue. Clojars itself is open source, and contributions are welcome in the Clojars GitHub organization.
Review period: April to 30 September 2026
Author: Daniel Compton
Context: I took lead maintenance responsibility for Clojars from April-June while Toby Crawley was away and went back to co-maintainer when he returned.

Read moreShow less

Clojars maintenance & support: April to June 2026

Account and supply-chain security

Stronger password requirements: I increased the minimum Clojars password length from eight to twelve characters #934.

Session invalidation after password changes: I reviewed an earlier proposal for invalidating sessions, then made a smaller implementation #944. When a user changes or resets a password, Clojars now removes that user’s active sessions. This was a fairly low-risk issue, but featured highly in bug bounty submissions.

Deploy and token notifications: I added a migration to enable deploy notification emails for old users created before this preference existed #943. This will help maintainers detect a supply-chain takeover or unexpected deploys.
I also reviewed and merged the work adding email alerts when a deploy token is created #945.

Compromised-password detection: I started working on Have I Been Pwned password detection in #951 but haven’t completed it.

Infrastructure reliability and resilience

IPv6, DNS management, and redundancy: I imported our DNSimple DNS records into Terraform so changes could be reviewed and audited. I also added IPv6 support for `repo.clojars.org.
I also added Route 53 as a second DNS provider alongside DNSimple (closing #60). This increases resiliency to DNS outage issues. This is not a theoretical concern, in February DNSimple had an DNS resolving outage which caused a Clojars outage.

Build and deployment repair: I fixed the Packer build #55. I also fixed the app uberjar so it can build on macOS case-insensitive filesystems #936.

I also made the instance-refresh script wait for AWS Auto Scaling to report success or failure instead of returning as soon as a refresh started. It reports progress and exits unsuccessfully for failed, cancelled, or rolled-back refreshes. I updated the Leiningen version on the production image to resolve a deployment problem.

Infrastructure as code: I moved the Clojars maintainer IAM group and its day-to-day AWS permissions into Terraform. This replaced console-only configuration with a Terraform maintained policy covering the services needed to operate Clojars, billing information, and Route 53 Domains.

I added contributor and coding-assistant guidance to the infrastructure repository and updated its setup documentation for DNS credentials and the Terraform workflow.

Development workflow

I added coding agent guidance to help guide usage of coding agents.

Clojars maintenance & support: July to September 2026

This covers Daniel’s and Toby’s work on Clojars.

Daniel’s work

Infrastructure hardening

Daniel changed the production EC2 launch-template configuration to require IMDSv2 tokens and limited metadata responses to one network hop. Before opening the change, he checked CloudWatch metadata metrics and exercised the real Cognitect AWS client against a local IMDSv2-only endpoint #61.

He prepared dedicated private database subnets with no internet or NAT route, moved the production RDS configuration into a private subnet group, disabled public accessibility, and enabled deletion protection #62.

Daniel also updated the Terraform backend to use the native S3 lockfile supported by current Terraform versions, removing the need for a separate DynamoDB locking table commit 357062b.

Toby identified that the production repository metadata still referred to the old HTTP URL in #966. Daniel then fixed the repo metadata to https://repo.clojars.org and managed the S3 object via Terraform #63.

Application tooling and maintainer review

Daniel updated the bin/clj-kondo bootstrap script to select the correct release for Intel and ARM64 builds on macOS and Linux and to keep architecture-specific cache directories. Commit 32cf33f landed on 20 August.

On Call management

Daniel added more monitoring for Clojars and setup an on-call 24x7 pager rotation for Clojars.

Security review

Daniel began a security review of the Clojars service. No major issues were identified, but some remediation work is in progress and will be published soon.

Toby’s work

Clojure Conj Talk

Toby gave a talk at the 2026 Clojure Conj about the history of Clojars and how it works. The video will be available on the ClojureTV YouTube channel soon.

Account, session, and authentication improvements

Toby simplified the normal login form so it only requests an MFA code after a user with MFA enabled has supplied valid credentials #950.

He moved sessions from the application’s memory into PostgreSQL, added a twenty-four-hour idle expiry, and added database cleanup for expired sessions. This makes longer-lived sessions practical and is one of the required steps for us to run multiple application instances - #964.

Toby tightened what the OAuth workflow returns so unserialisable provider data would not leak into session state, then corrected the workflow response handling after GitHub login failed in production. These fixes landed directly as commit 1896a51 and commit e9542cf.

He also updated GitLab login to accept all email addresses that GitLab marks as verified instead of requiring the address to be primary #971. Separately, #969 added the Clojars account name to notification-email footers, making messages unambiguous for people who have more than one account associated with an address.

Supply-chain and dependency security

Toby updated Jackson, the PostgreSQL driver, and Logback to address reported vulnerabilities in #956, then handled another set of dependency vulnerabilities and cleaned up obsolete or spurious suppressions in #965. A further September dependency update addressed eight CVEs in commit 67727be.

He prevented publication of POM and Gradle module metadata containing dynamic dependency versions, e.g. version ranges like [16,17), RELEASE, or SNAPSHOT #960.

Toby removed the separate Apache HTTP client stack in favour of the http-kit client already in use, reducing the dependency and CVE surface #961. He also switched from a temporary fork of the one-time library back to its official release #962.

Development, CI, and operational reliability

Toby updated clj-kondo, adopted its new constant-condition linter, and fixed the issue the new check found #963.

After removing Apache HttpClient, he fixed Fastly cache purging by switching to Fastly’s POST API, which the http-kit client supports. Commit 0c7e45a landed on 22 July. He also aligned the local Docker Compose environment with the PostgreSQL 18.4 version used in production in commit 61eb196.

Toby replaced the unavailable MinIO test image with S3Mock, updated the CircleCI base image, and repaired Maven dependency caching that had not been using the intended path #970.

His infrastructure maintenance increased Fastly log retention for analysis, corrected that policy so it actually retained 120 days, and fixed the instance-selection script so status messages no longer contaminated the IP address returned to callers. These changes landed as commits 5ebe01a, a994755, and f615c38.


Malli: Ambrose Bonnaire-Seargent

Q2 2026 Final Report 3. Published October 15, 2026

In this project, I am tackling exponential growth related to Malli refs. This is my final update of three. Thanks to Clojurists Together and Metabase for supporting this project.

I concentrated on three activities for this time period.

Draft PR: https://github.com/metosin/malli/pull/1284/changes

First, I added documentation to my branch explaining what the proposed changes to malli.core enables. See the README.md changes in my pull request.

Second, I added more complex test cases for the new cycle detection in schemas. There were a few useful test cases I introduced years ago to test cycle detection in malli.generators, and now I have repurposed them for malli.core tests as the cycle detection has moved there.

Third, I took some time to deeply consider why a subtle condition is needed in malli.generator, that disables caching for generators if we’re currently under a gen/recursive-gen. Again, this was something I added years ago but didn’t fully understand its function. Earlier in this project, I had removed the condition, but it caused the tests to fail. It resulted in this small documentation change. I don’t know if we can improve the caching in this area of code, and it’s a potential area of future work. I suspect there’s more to learn here, and it may reveal a more effective approach to creating generators.

I’m pretty happy with the code, overall it elegantly resolves several hacks deep in Malli’s design while either conserving or improving performance, and exposes them such that extensions can better support recursive schemas. I need to do some work to update the PR description and communicate my proposal to the Malli maintainers.

A goal of this project was to address a hack in Metabase around the performance of ref schemas. I think this work has brought us closer to that goal by better understanding how to detect cycles in schemas and when it is safe to reuse schema objects. The project concentrated on a smaller scope, mainly property registries, and I documented some ideas for how we could generalize the benefits to more complex registries. More importantly, the time spent considering the smaller scope will give greater confidence that such generalizations will actually be correct and a good idea. I hope to find out soon.


SciCloj Documentation and Plotting Libraries: Cvetomir Dimov

Q2 2026 Final Report 1. Published September 30, 2026

During the first month, I worked on both documentation and libraries. In terms of documentation, I mostly finished with the redesign of the Noj website. The website now looks much more modern with a theme inspired by the Macroexpand conference website and a main page. It also makes sure to spell out Noj’s main strengths, it presents the main libraries in an appealing way, and links to Zulip, Github and SciCloj. Moreover, the website is now structured to have separate sections for a quick start, for a book to learn data science with Noj, and for library references.

In terms of library development, initial steps have been made toward a new library for interactive dashboards and for porting the dataset library Tablecloth to ClojureScript. Specifically, the repository of the interactive dashboard library has been initiated, a feature list has been created and the base architecture has been designed. Currently, the process for selecting the required technologies is taking place. For Tablecloth, I have started studying the design of the existing Clojure library.

Posts that Hacker News liked

The following is a list of my articles and projects that I think made it to the front page of Hacker News (they each got at least 29 points, and as I write this there's a post on the front page with 29 points).

27 total, six of which are Show HNs. Of the three posts that had more comments than points, two are about advertising.

Some of the links from the HN posts were broken, so I've changed the links here. Too lazy to set up redirects in some cases, unfortunately. I've also removed links for most of the Show HNs since the projects they discussed are defunct. I merged The Sample into Yakread, so it kind of lives on.

editmd 0.1.0

I made a markdown editor for MacOS! With Claude writing most of my code now I spend a lot more time than I used to editing docs. I prefer working in markdown, and wanted a really simple wysiwyg editor that works like a word processor but saves files as markdown.

I often work with agents on docs now, so I needed an editor that would not break my workflow if the file was edited while open. I will often be writing and have an agent go search for references, for example, or add links and references, or fill in quotes that I only vaguely remember, or add code examples to a spec, or any number of other things, so I wanted something that would let me keep working seamlessly while the file I had open was also being editing by agents at the same time.

Most editors either clobber one side or throw up a "the file changed on disk" dialog. editmd uses Y.js to merge your agents edits in real time, like you're collaborating on a google doc. If an agent edits the file while it's open, its changes just appear, your unsaved edits stay put, and Cmd-Z only undoes your own changes, never the agent's.

Screenshot of editmd

Try it out

It needs macOS 14 or later, and runs on both Apple silicon and Intel:

brew install --cask kirahowe/tap/editmd
open -a editmd notes.md

It's not signed with an Apple Developer ID (yet?), so the cask clears the quarantine flag for you. If you grab the zip from the release page directly, run xattr -dr com.apple.quarantine editmd.app before opening it. There's also a little website at kirahowe.github.io/editmd with some elaboration of the features.

How it works

It's a native AppKit document app with a WebKit view running a Milkdown editor (ProseMirror + remark), all bundled into the app so nothing loads from the network. The document in each window is a Y.js CRDT. When the file changes on disk, editmd diffs the new text against the version it last saw, replays the difference as edits block by block, and lets Y.js merge them with whatever you've typed since. Edits to different paragraphs, list items or table rows combine cleanly.

Documents autosave one second after you stop typing (or every five seconds if you don't stop). A file you open and save without editing keeps its exact bytes. An edited one keeps its encoding (UTF-8, UTF-16 or Windows-1252), its line endings, its YAML or TOML front matter, reference-style links and tables. GitHub flavoured markdown works so tables, task lists, strikethrough, footnotes, and GitHub's heading anchors all render properly, so a table of contents written for GitHub works as expected. It also supports mermaid diagrams so fenced mermaid blocks render as diagrams with a live preview while you edit them. Clicking on a diagram shows the raw markdown, or pressing Cmd-/ flips the entire doc to the raw markdown.

It's also safe to open external (i.e. untrusted) documents. Cmd+click on a link never launches anything — text files open in editmd, and any other file, folder, or app is just shown in Finder. Remote images are blocked until you allow them, like in Mail, so a document can't leak any information via an image fetch.

On using AI to ship software

I have found working with AI to build software increasingly fun. Obviously this was built with heavy assistance from Claude (I primarily use Opus 5.5 right now), but it is still very tailored to my taste and needs. I am also coming to appreciate how much better at coding Claude is than I ever was. It's kind of sad to admit defeat, but it's really just true that it catches things I don't now. Once I thought this was ready for release, I asked Claude to QA rigorously before putting up a release, and it found a bunch of stuff I definitely would have shipped:

  • If WebKit's content process crashed, the window reloaded stale text, and the next autosave silently overwrote changes that had already been saved
  • Cmd-clicking a link to an .app in a cloned repo would launch it, with no Gatekeeper prompt since cloned files aren't quarantined
  • Opening a document fetched its remote images, i.e. tracking pixels
  • Windows-1252 files got silently converted to UTF-8, and UTF-16 files opened as garbage
  • Running 485 generated markdown cases through the serializer turned up real content loss: badges ([![build](…)](…)) lost their links, tables with ragged rows shifted their columns, and unused link definitions just disappeared

Normally these are the kinds of issues I would have never noticed until they screwed over a real user, but we don't have to accept these kinds of problems in our software anymore! I just told Claude to spin up a bunch of parallel agents to fix all these problems and it fixed seven different issues, each in its own git worktree, each one going through implementation → independent code review + adversarial QA → fix, until two reviewer agents signed off. It ran 30 sub agents and took about two and a half hours. I had a main agent doing integration, which still caught yet another real bug — two of these workflows had each taken over the markdown serializer, and one of them had broken reference links. Every fix came with regression tests, and the test suite grew from 65 web tests to 762.

Known limitations

There are still some known issues due to the fundamental complexities of merging concurrent edits to a single document:

  • If you and another program edit the same paragraph at the same moment, the paragraph keeps both changes, which may need manual tidying.
  • A save checks the file one last time before writing, but an outside write that lands in the few microseconds between that check and the write still gets overwritten. Nothing can realistically close that gap without the other program's cooperation.
  • Links with custom schemes (obsidian://, file://) don't do anything on Cmd-click in the rendered view yet.
  • Very large files (hundreds of KB) are fine to type in, but the first autosave after opening still serializes the whole document.

This is the very first release, so there are definitely still rough edges, but I'm already using this every day now. If you try it and run into anything, please open an issue on the repo. Thanks for reading!

release (link) / #coding-agents #developer-tools #editmd #macos #markdown #open-source #release

slk 0.30.0

This release adds a read-only viewer for slack canvases that pops over most of the screen and looks like this:

slk's canvas viewer

It also adds an unread section that surfaces unread messages to the top of the side bar, which I needed because many of my workspaces have far too many channels so unread get buried. It looks like this:

new unread section

Details about using both are in the release notes. This release also includes many fixes related to connections dropping, and a fix for config breaking for new users thanks to Avi Flax in the repo's first external PR (thank you!).

release (link) / #developer-tools #open-source #release #slack #slk #tui

slk 0.29.2

These two releases fix a few small bugs and bring in two recent improvements from upstream.

v0.29.2

This one fixes some data races in the workspace sidebar and finder lists.

v0.29.1

There was a bug in the threads and activity views where keys could act on a message that wasn't visible anymore, and some jankiness rendering selected cards in the threads, the background selection tint now runs the full width of the line of each row in the threads view.

A fix for a data race that happened on connect and reconnect was pulled in from upstream.

release (link) / #concurrency #oss-updates #release #slack #slk #tui

Clojure 1.13.0-alpha10

Clojure 1.13.0-alpha10 is now available! Find download and usage information on the Downloads page.

Destructuring changes

To make what were (implicitly optional) top-level bindings required, you can now move them into a map under a new :req! directive. This includes bindings to different names or nested destructuring, e.g. {a :long-a} ⇒ {:req! {a :long-a}}.

:namespaced/keys[!] directives now qualify any unqualified keywords. Note that this may be a breaking change from the prior alphas for unqualified keys after the & where unqualified keys were not interpreted as qualified.

Inliner updates and macro invocation tag preservation

Previously, :inline on function definitions needed to work for all inlines. Inline is now conditional and the inline definition can return nil to indicate the function can’t be inlined, and the non-inlined function should be used instead.

get-in now inlines when passed a literal vector of keys.

Type hint metadata on a macro invocation is now conveyed on the expansion, which may be wrapped in a call to identity if needed. A type hint tag on the macro invocation will dominate one on the expansion when they disagree.

Try it out

Update your deps.edn :deps with:

org.clojure/clojure {:mvn/version "1.13.0-alpha10"}

Start a REPL with the Clojure CLI (any version) with:

clj -Sdeps '{:deps {org.clojure/clojure {:mvn/version "1.13.0-alpha10"}}}'

From the Outskirts: Q3 2026

From the Outskirts is a roundup of my open source work in Q3 2026.

In the last update, I mentioned a DNS library and hoped to get a public preview of Busker out during Q3. Both projects are now public.

There's also been some work on FFI (native interop) in the Clojure ecosystem that I'm particularly excited about.

In this edition of From The Outskirts:

Busker is in public preview

Busker is a Clojure web server built on libh2o (the web server core that powers Fastly's global CDN) serving Ring applications over HTTP/1.1, HTTP/2, and HTTP/3 with automatically provisioned TLS certificates (now Clave makes sense?). I've soft-launched it, no big announcement yet, so others can preview it and provide feedback before APIs solidify. It's available to try, but still experimental and not recommended for production use.

But why another Clojure web server? I'll expand on this in a future Rationale section of the Busker docs, but it boils down to simple deployments.

My clients value simplicity and affordability in the services I build and run for them (much of my work is operational, keeping servers online and healthy). Funding for these projects is grant-based, and between grants we have to run lean and mean. Every component in the deployment system is something that can fail. So I want to minimize all the moving parts.

Busker helps with this because it lets me embed the web server inside the Clojure application. I already embed the database+queue etc in the application also. Deployment is as simple as scping an uberjar (that can generate its own systemd service unit file) to a Linux virtual machine.

An internet-facing HTTP stack is a bad place to indulge in reinventing things. That's why another pillar of Busker is libh2o: we build on the H2O maintainers' work and Fastly's expertise rather than rolling our own HTTP implementations. Busker already gives me enough security-sensitive work without volunteering for another stream of incoming CVEs.

More on Busker in another post. The documentation has setup instructions and examples if you'd like to experiment with it. Send me feedback!

Native libraries from Clojure and Babashka

ol.vips and Busker's native bindings are now using babashka/ffi. borkdude was inspired created babashka/ffi after some cross-pollination between Anders Murphy and myself.

An FFI, or foreign function interface, lets you call functions in native libraries from another language. In this case, that means calling them from Clojure and Babashka, including ordinary bb scripts.

Before babashka/ffi I was using coffi, the library that babashka/ffi is inspired by. And I want to take a moment to give props and thanks on Joshua Suskalo, the coffi author. He pioneered this approach to easy native interop in Clojure, using Java's new Foreign Function and Memory API.

But why switch to babashka/ffi? Compatibility, simply put. It supports both Clojure and Babashka. It's built into Babashka itself, whereas coffi is not Babashka compatible. That opens up native libraries to small Babashka scripts, not just full JVM applications. You can load a library, describe the functions you need, and call them from the script. With the same babashka/ffi API you can also run code in Clojure on the JVM.

Example: libvips is a very well respected library in the image processing world, primarily for security, speed and extremely low memory footprint (compared to alternatives like ImageMagick). ol.vips is my Clojure library for image processing that wraps the native libvips shared library and provides a nice Clojure interface. As of this post it is ported to babashka/ffi but is not 100% compatible yet. I'm waiting on a small issue in babashka to be fixed.

Oh and in sqlite4clj I landed the coffi -> babashka/ffi migration also. Are you using SQLite in Clojure? Are you still using JDBC? Boo! Check out sqlite4clj!

This is what excites me about Project Panama and the JVM's Foreign Function and Memory API! Clojure was designed as a hosted language and taking advantage of the JVM library ecosystem has definitely been a boon. But often the Java libraries we want to consume are bloated and/or expose cumbersome object-oriented APIs that are ungainly in Clojure.

I think Clojure has a great future making use of native libraries instead of reaching for another bloated Java dependency. Like with libh2o and libvips, there are substantial existing implementations to build on, while the application-facing code can remain Clojure. Native libraries still need packaging and the bindings still need to get memory management right, but I'm glad to have this option in both Clojure and Babashka.

Mozilla Fluent tooling

Fluent is Mozilla's localization system for the text an application presents in different languages. It's been around since roughly 2017 and is in my opinion the best system for creating and managing translations of strings for software projects that don't bend the knee to English.

Take a selected-file count, with $count passed in as a number:

English (en.ftl):

selected-files = { $count ->
    [one] One file selected.
   *[other] { $count } files selected.
}

Arabic (ar.ftl):

selected-files = { $count ->
    [zero] لم يتم تحديد أي ملفات.
    [one] تم تحديد ملف واحد.
    [two] تم تحديد ملفين.
    [few] تم تحديد { $count } ملفات.
    [many] تم تحديد { $count } ملفًا.
   *[other] تم تحديد { $count } ملف.
}

English needs only two plural categories, but Arabic has six! With fluent-clj, the application supplies the same message ID and count:

(require '[noahtheduke.fluent :as fluent])

(def en (fluent/build "en" (slurp "en.ftl")))
(def ar (fluent/build "ar" (slurp "ar.ftl")))

(fluent/format en :selected-files {:count 2})
;; => "2 files selected."

(fluent/format ar :selected-files {:count 2})
;; => "تم تحديد ملفين."

However, suppose that the message we wanted to show addresses the reader with the 2nd person pronoun "You":

"You have two files selected."

Arabic distinguishes masculine and feminine forms of singular "you", but English's "you" doesn't.

Assuming we have the gender of the reader then this becomes in fluent:

English (en.ftl):

selected-files = { $count ->
    [one] You have one file selected.
   *[other] You have { $count } files selected.
}

Arabic (ar.ftl, [two] branch only):

...
    [two] { $gender ->
        [masculine] لديكَ ملفان محددان.
        [feminine] لديكِ ملفان محددان.
       *[other] تم تحديد ملفين.
    }
...

Unlike plural categories, masculine and feminine are labels we chose for an application-supplied $gender string. Fluent doesn't infer the person's gender. Passing "unspecified" uses the neutral fallback, "Two files have been selected." Rebuild the bundles from the updated files:

(def en (fluent/build "en" (slurp "en.ftl")))
(def ar (fluent/build "ar" (slurp "ar.ftl")))

(fluent/format en :selected-files {:count 2 :gender "feminine"})
;; => "You have 2 files selected."

(fluent/format ar :selected-files {:count 2 :gender "feminine"})
;; => "لديكِ ملفان محددان."

(fluent/format ar :selected-files {:count 2 :gender "masculine"})
;; => "لديكَ ملفان محددان."

The English translator/writer doesn't even have to know about the $gender for these strings. Arabic uses it to choose the wording.

Fluent is powerful and enables you to produce native sounding applications for all languages. If I'm building software for people, getting their language right is part of the job.

Fluent, however, does have a tooling problem, especially if you don't want to use the npm ecosystem. It can be cumbersome to work with fluent files in your editor as there's no linter or highlighting.

As part of a client project I was able to build some tooling which I can now open source under outskirtslabs/fluent-tooling. It includes :

  • tree-sitter-fluent, an error-tolerant Tree-sitter grammar
  • ftl-lint, a command-line linter for Fluent .ftl files with compiler-style diagnostics
  • and fluent-ts-mode, a Tree-sitter major mode for Emacs with syntax highlighting.

The docs cover the individual tools and how to use them.

Protocol53

ol.protocol53 provides one Clojure interface for managing DNS records across providers. Available provider integrations include Cloudflare, deSEC, GoDaddy, and Porkbun.

It grew out of Clave's need to handle ACME DNS-01 challenges without duplicating the integration work for every DNS provider.

Example usage:

(require '[ol.protocol53 :as protocol53]
         '[ol.protocol53.cloudflare :as cloudflare])

(def dns (cloudflare/provider {:api-token (System/getenv "CLOUDFLARE_API_TOKEN")}))

(protocol53/append-records! dns
 "example.com"
 [{:name "www"
   :type "A"
   :ttl  60
   :data "192.0.2.1"}]
 {:timeout (java.time.Duration/ofSeconds 10)})

See the documentation and API reference.

Further Out: Q4 2026

Stardust DB has landed in public preview. It's a data engine from the creator of Datastar. Think SQLite + Datomic + Redis + Elasticsearch + NATS + Object Storage all in one small binary. It has crazy potential, so I'll be poking and proding at it over the next months to learn more.

Busker's performance is pretty good, but it can be better. My goal is to be within 20% of vanilla h2os numbers for h1, h2, and h3.

That's all from the outskirts. Back to the lab.

‣ Casey

Reply via: Email · Bluesky

volley 0.1.0-alpha1

I made a test runner for Clojure! Clojure is still my language of choice and I've been writing most of my personal projects with it (or babashka) lately, and especially in the babashka projects have been wishing for a better test running experience. I use vitest at work, so it inspired a lot of the functionality of volley.

It's a runner for clojure.test tests and currently works on babashka and JVM Clojure. It runs test namespaces in parallel worker processes, shows each namespace as it finishes, and has a watch mode that reruns only the tests that change. It's compatible with kaocha, since that's what I was mostly using before, so switching is just changing one command. With AI generating more and more of my code these days, I find I'm running more and more tests, and was wanting a runner that could parallelize large test suites where that results in noticeable speedup.

This is what volley looks like running its own test suite:

volley running its own test suite

Try it out

volley is on Clojars as com.kirahowe/volley. For bb projects, add it to bb.edn with a task:

{:paths ["src" "test"]
 :deps {com.kirahowe/volley {:mvn/version "0.1.0-alpha.1"}}
 :tasks {test {:doc "Run the tests"
               :requires ([volley.main])
               :task (apply volley.main/-main *command-line-args*)}}}

On JVM Clojure projects that already use kaocha, swap lambdaisland/kaocha for com.kirahowe/volley in :extra-deps, and change kaocha.runner to volley.main in :main-opts, and leave the rest alone. A bin/kaocha binstub that runs clojure -M:test "$@" should keep working.

How it works

The parent process plans the run and hands namespaces to workers, which stay alive across runs. By default, it runs one worker per CPU, capped at 8 for bb and 4 on the JVM. --workers N overrides these, and --isolate gives every namespace a fresh worker. Workers run on whichever runtime the parent uses, or you can specify directly with --runtime bb|jvm. Timings are saved in .volley/, so later runs start the slowest namespaces first.

In a terminal, volley re-draws one line per running namespace. When stdout is piped, it prints plain lines instead. Failures show expected and actual values, a structural diff for = assertions, captured output, and the call stack without the bb interpreter's frames. A failed defspec property test shows its seed and shrunk input. The run ends with the slowest tests and prints a command that reruns just the failures.

--watch polls test and source paths, works out from ns forms which test namespaces depend on the file you changed, and reruns only those. Keys let you rerun everything, rerun failures, or filter by namespace or test name.

Most of kaocha's options and config are supported by default. Suite ids, --focus and --skip (and their -meta forms), --fail-fast, the reader tags in tests.edn, :kaocha/bindings, pending tests, the hooks plugin, JUnit XML, custom reporters, the --print-* flags, and kaocha's exit codes should all work as expected.

Performance

So far I've only tested this on my busy 16-core M4 max MacBook Pro, but I have found huge improvements on big test suites. In one bb project with 507 tests runs went from ~150s to ~30s with 8 workers, on another JVM Clojure project I saw improvements from ~215s to 40s. So far small JVM Clojure test suites don't see much improvement in my experience. This is on my radar to improve, but for now if your tests run in under about 10s, you might not see any improvement with volley because the JVM startup overhead dominates the timing.

Known limitations

  • Only handles clojure.test for now. There's no ClojureScript and no coverage.
  • Kaocha plugins such as the notifier, gc-profiling and cloverage are accepted but do nothing. volley prints one warning pointing this out.
  • --randomize and --seed are accepted with a warning. This release doesn't randomize test order.
  • volley departs from Kaocha on purpose in a few places: load errors, System/exit inside a test, fail-fast inside a test, load order, and the --print-* maps. There are more details in the "Deliberate divergences from Kaocha" section here.

This is the very first alpha release so there are still many rough edges, but I'm using it all day every day on my own projects so figured it's ready "enough" to put out there. My goal is to keep improving it to fix all the paper cuts. If you try it and run into any, I would love to know about it. Please feel free to create issues in the repo or message me in any of the Clojure community places I lurk. Thanks for reading!

release (link) / #babashka #clojure #developer-tools #open-source #oss #performance #release #testing #volley

The code was the easy part: Lessons from contributing to the Clojure language

Written by: Arthur Fücher

I always wanted to contribute to Clojure. Not building a new library, but improving the language itself. Working on a programming language felt too hard to me, a senior software engineer.

During a mentorship at Nubank, I told Alex Miller that I hoped to reach that point someday. His response was roughly: “You can do that today.”

Not everyone has the opportunity to have a Clojure core contributor mentor them. Alex’s point was simple: the tickets are public, the contribution process is clear, and there are real problems to solve now.

The mentorship became: pick an open ticket, work on it, learn.  And I worked on two Clojure core tickets. CLJ-2897, about dynamic library loading through prepl, merged and included in Clojure 1.13.0-alpha5. CLJ-1548, about primitive type hints in protocols, still in review when I presented this experience at Clojure/Conj 2026.

I expected the code to be the hardest part, and it was hard. But it wasn’t the hardest part: that turned out to be four questions. Questions that I had to answer before anyone would even look at my code.

Read moreShow less

1. What is actually wrong?

My first ticket involved Clojure’s prepl, a programmatic REPL. Like a conventional read-eval-print loop, it accepts expressions and evaluates them. Unlike a conventional REPL, it emits structured results. A developer can connect to a running process through a socket and evaluate forms..

Using  add-lib, which add dependencies to a running REPL session, failed in prepl:

That was an odd message to receive while using a REPL. The ticket identified the immediate cause: prepl did not bind *repl* to true. 

My first patch added the binding. It was a two-line change that addressed the ticket’s stated cause. I attached the patch. I felt great.

Then Alex pushed me to consider what the user wanted to do. They didn’t want a particular value for *repl*; they wanted to load a library.

I applied the patch and tested, the original error disappeared. But now another error message: Context classloader is not a DynamicClassLoader.

Same user. Same goal. Another error.

add-lib requires a DynamicClassLoader. Prepl hadn’t set one up.

My revised patch added both the *repl* binding and the classloader setup. Now, the prepl session could then add Cheshire, require cheshire.core, and call it.

The first patch removed the reported error, while the second made the user’s task work.

I had even seen a reference to the related classloader issue, CLJ-2540, in the original ticket. I read it without understanding how central it was. Reading a ticket and absorbing a ticket are different things.

2. Why does the current behavior exist?

Once the patch worked, I needed to understand why prepl lacked those pieces in the first place.

The explanation was historical. Prepl was created in 2018 by copying and modifying clojure.main/repl. But it didn’t keep the REPL’s DynamicClassLoader setup. Later, in 2023, clojure.main/repl gained the *repl* binding, but the change didn’t reach prepl. 

Two implementations with related responsibilities had drifted apart. This was a consequence of how the code had evolved.Understanding this raised a bigger question: should fix the drift? Or the symptom of the drift?

My second ticket exposed a different kind of mismatch.

In Clojure, we have primitive type hints. It tells the compiler to use a primitive-aware invocation rather than boxing a value. With ordinary functions, that can be a useful optimization. But consider this protocol example from CLJ-1548:

Defining the protocol works. Extending it for strings works. Directly calling the function fails.

The reason is subtle. defprotocol accepts the primitive hint but doesn’t generate a corresponding primitive-specialized method. Yet the hint remains in the function’s :arglists metadata. When the compiler reads that metadata and selects a specialized invocation. In this case IFn$OLO, an interface the generated protocol function doesn’t implement.

The JVM cast fails before dispatch to the string implementation can happen. The function’s metadata promises a calling convention that the generated function cannot fulfill.

Clojure 1.12 already included a fix for a similar issue, about primitive return hints, CLJ-2698. But CLJ-1548 concerned primitive parameter hints, and understanding the difference mattered.

“Why does this happen” is not a curiosity question. It’s the only way to decide whether the fix belongs..

3. What has already been considered?

Both tickets contained more accumulated reasoning than I first appreciated.

The prepl ticket had pointed to CLJ-2540 all along. A triage process expanded the protocol ticket with the related return-hint precedent. and possible solutions. I wasn’t arriving at untouched problems. Other contributors had already done important investigative work.

And yet simply reading those notes wasn’t enough. My first prepl patch proved that I could read a clue and still underestimate it.

After the revised prepl patch worked, Alex encouraged me to implement alternatives. Not only think about them. And we ended up comparing three approaches:

  • Copy the missing behavior into prepl. Add *repl* and the DynamicClassLoader setup. The patch was small, five added lines and four removed, but it wouldn’t prevent future drift.
  • Rebuild prepl around clojure.main/repl. Reuse the existing REPL’s extension points, potentially reducing divergence. Experimenting with this revealed complications involving shared state between read and evaluation phases, prompt behavior.. The prototype involved 65 additions and 44 removals.
  • Extract shared classloader setup. Introduce a reusable macro used by both REPL implementations. This provided limited reuse but still didn’t eliminate the drift.

The experiments mattered because they replaced an attractive architectural idea with concrete trade-offs. An approach can look elegant until it has to preserve existing exception handling, output structure, and other behavior that real clients expect.

In total, I wrote 126 lines of experimental code that wouldn’t be part of the accepted change. And I want to be clear: it wasn’t a waste. It was the cost of knowing different dimensions of the solution. You can’t know the simple option is correct until you’ve seen what the complicated ones actually cost.

4. What quietly rules most options out?

The approach that best prevented future prepl/REPL drift wasn’t selected.

Rebuilding prepl on top of clojure.main/repl offered the clearest reuse story. But getting the behavior right will need modifying repl itself. And repl has external users who may depend on details of its existing behavior. Changing it to fix prepl would impose risk on code that hadn’t experienced the original bug.

We chose the smaller, less disruptive patch. It solved dynamic library loading in prepl without claiming to solve future divergence. We documented that limitation rather than hidden it.

The protocol ticket made compatibility even more concrete. One proposed solution was to reject unsupported primitive hints at definition time. 

To me it was obvious:  Why let developers define something that will fail when called? 

But the answer was: Because not every call fails.

Let’s check this example using a protocol function named add (the definition is omitted here):

In a direct call, the compiler sees the primitive-hint metadata and calls .invokePrim.. Passing the function as a value uses plain IFn.invoke, not invokePrim.

That means there may be code with primitive hints on protocol methods that has worked fine for years.Rejecting those definitions would break code that currently runs.

So, the alternatives had different consequences for existing users.

On both tickets I had more than one option that worked. What was decided between them had less to do with the code, and a lot to do with everything around it.

The final challenge: Explaining the reasoning

By this point, I had spent time reconstructing behavior, implementing alternatives.  Ready for the next step: Screening/prescreening process. I thought I had done the difficult work.

Then Michael Fogus screened the protocol ticket and left six comments. None were about the code. They concerned the description: put the problem first, introduce context in the order a new reader needs it, and make the argument for the change easier to follow.

I rewrote the ticket’s explanation, but the patch stayed the same.

That feedback connected with something Peter Naur wrote in Programming as Theory Building (1985): 

“[…]programming properly should be regarded as an activity by which the programmers form or achieve a certain kind of insight, a theory, of the matters at hand.”

And he says one more thing: the theory lives in people. It does not fit entirely in the text. So if the theory is in my head, and the decision is in someone else’s, how do I move it across?

The four questions I had been answering were ways of building a story. A story that moves a theory from one head to another. 

The evolution of CLJ-2897 made the contrast visible. Its initial description had 82 words; the final one had 505. Alongside, were 9 lines of accepted code and 126 lines of experimental work written to be thrown away.

It shows that for a patch to move, the story, the theory had to be in the head of the person deciding.  A ticket isn’t paperwork, it is the transfer of understanding.

What contributing to Clojure core taught me

The prepl patch was ready in August 2025 and reached Clojure 1.13.0-alpha5 in July 2026. The protocol ticket, opened in October 2014, was still in progress when I wrote this..

That pace was frustrating. And it’s slow for the exact reason I am sharing about this experience. Nobody there accepts code before they accept the theory.

I came into this experience prepared to write code. What I wasn’t ready for were the four questions. And the fifth thing: writing it down as a story that builds the theory in someone else’s head.

Those activities were the real contribution, alongside the code.

I still think about Alex telling me that I could work on Clojure today. He wasn’t promising a quick patch or an easy review. He was pointing out that I could start learning from a real problem immediately.

Want to contribute? Explore Clojure’s issue discussions, its ticket tracker, and the official contribution workflow. Choose a problem and investigate what people are actually trying to accomplish. Read the history. Try the alternatives. Explain what you learn.

You don’t need a mentor to ask what the user is actually trying to do. 

You can do that today.

The post The code was the easy part: Lessons from contributing to the Clojure language appeared first on Building Nubank.

Advent of Clojure: NFS 2026

This is a sequel to my AoC: NFS blogpost from 2023. Here I’ll further explore some ways to improve the performance of Clojure code, based on my AoC 2025 solutions.

Using a correct algorithm is still the best way to improve the performance by a huge margin. In that category, but with a smaller gains, is also “don’t do unneeded stuff” (e.g. creating a vector of some elements when all you’re interested in is the count of those elements).

All stuff mentioned in the original post is still valid, and here will be some additional things to be aware of. This time I’ll keep it brief.

clj-async-profiler

In the original post I mentioned Criterium. This time it is time to mention another very useful profiling tool: clj-async-profiler.

Its documentation website gives tips on how to use it and how to read, explore and understand the flamegraphs, etc.

Read moreShow less

Thanks to it, I managed to find various time-wastings in places where I wouldn’t expect.

Reflections

In the original post I mentioned (set! *unchecked-math* :warn-on-boxed). Another useful setting is (set! *warn-on-reflection* true), which shows warnings for places where reflection is needed to resolve method calls or field access. For example:

(defn my-func [x y]
  (Math/floorDiv x y)))

; Reflection warning, (...)/user.clj:96:5 - 
; call to static method floorDiv on java.lang.Math can't be resolved 
; (argument types: unknown, unknown).

The solution is to type-hint the function arguments:

(defn my-func [^long x ^long y]
  (Math/floorDiv x y))

It is not needed to fix this warnings everywhere, but in some tight loops might give some surprisingly large benefits.

Loop variables

(defn some-func [xs]
  (let [n (count xs)]
    (loop [i 0
           acc 0]
      (if (>= i n)
        acc
        (recur (inc i)
               (+ acc (xs i)))))))

The loop variables i and acc start as long, but the result of (xs i) is a boxed number, so after the first iteration the acc variable becomes boxed too. This is also caught by the *warn-on-reflection*:

recur arg for primitive local: acc is not matching primitive, had: Object, needed: long
Auto-boxing loop arg: acc

The solution is to convert the result back to long: (long (xs i)).

The significance of this improvement depends on the speed of the rest of the loop. In the above case, where loop is quite simple and fast, the improvement is 25%.

(>= start 1 0 end)

For one task there was a condition that start must be positive and end must be zero or less. I wrote it in a single >= as (>= start 1 0 end). The problem? The >= function for more than two arguments uses variadic arguments ([x y & more]), which causes a slowdown compared to the version with just two arguments.

The solution: use (and (pos? start) (<= end 0)). This way the hot loop this was in gained a 4x performance boost.

Deeply nested for

In one task I had a for comprehension which looked something like this:

(for [x xs
      y ys
      z zs
      w ws]
    [x y z w]) ; real example was more convoluted than this

A bit faster approach was to use doseq instead:

(let [result (atom (transient []))]
  (doseq [x xs
          y ys
          z zs
          w ws]
    (swap! result conj! [x y z w]))
  (persistent! @result))

I don’t expect this to work everywhere nor to produce meaningful differences every time, but in this specific case it was worth it.

Java stuff

  • Long/toString: If you know your input is a number, it is faster to convert it to string by using the specialized Long/toString method than go through the more general str function.
  • .charAt: Faster to do (.charAt s idx) than (get s idx), and much faster than (first s) for the first character.
  • long-array: Sometimes it makes sense to use Java arrays instead of Clojure’s vectors.

Conclusion

That’s it for this time. Now we’re ready for pointless optimizations for AoC 2026, coming out in few weeks time.

2026 Nominations and Your Vote

Clojurists Together Foundation is in the process of electing new board members as three of our existing board members are finishing their terms. One member, Lorelai Lyons, has submitted an application for another two year term.

Departing members

Two others, Laurens Van Houtven (lvh) and Maria Geller have decided to step aside to open the door for other Clojurists to participate. Maria has been a board member since Clojurists Together was first formed in 2017, and lvh was elected in the first board election in 2018. lvh has been President of the Clojurists Together Foundation since we formed our own non-profit trade association in 2020 and put in a huge amount of time and energy to leave the Software Freedom Conservancy and form our own non-profit. I want to extend a very special thanks to them for their years of service and leadership in the community, and will be sad to see them leave. They were both instrumental in helping Clojurists Together get to where it is today.

Read moreShow less

Election

After accepting applications for nominations, we received 5 applications from candidates - who will serve two-year terms. These candidates are all talented Clojurists that we would love to have on the board.

Our bylaws state:

The Board shall nominate no more than 12 candidates seeking board membership in any given election. In nominating candidates for Director positions and in choosing the number of candidates to nominate overall, the Board shall use reasonable efforts to maintain a Board composition consisting of at least: (1) 25% female Directors, (2) 25% non-Caucasian Directors, and (3) 35% from any category(ies) of persons (e.g., race, gender, ability) commonly considered to have suffered from discrimination at some time and then-currently under-represented in the technology industry, in each case as determined by the Board in its reasonable discretion.

The board has nominated the following 5 candidates for the current board election:

Arthur Fücher
Henning Jansen
Lorelai Lyons
Chris Oakman
Mohit Verma

Next Steps

All current Clojurists Together members will be receiving their ballot to vote shortly. If you haven’t received a ballot by Wednesday, 22nd October, please contact hi@clojuriststogether.org.

Voting will close on Monday, 9th November, 2026 at midnight Pacific Time. The top three candidates, as voted on by members will win. We will announce the winners shortly afterwards and you’ll be able to meet them at the Annual members meeting at 10 am Pacific time, Nov. 17th, 2026. The new board will meet shortly after the annual meeting to select officers.

Candidate Profiles

Below we have published the candidates applications (in alpha order):


Arthur Fücher

Company: Nubank
GitHub: https://github.com/afucher
LinkedIn: https://www.linkedin.com/in/arthur-fucher

Why they’d like to join
I love Clojure and its community. I want to help it grow where it is still small.

Advocating for Clojure is already part of what I do. I run Clojure BR, the Brazilian Clojure community. It is a personal project, not tied to any company. We do meetups, livestreams, a newsletter in Portuguese, and more.

I would bring a non-US, Portuguese-speaking view to the board. I live in Brazil, so I can help the board stay close to maintainers and companies in Latin America. I can also support advocacy and content in Portuguese.

I also bring years of paid Clojure work, focused on developer experience and tooling. And I contribute to open source, including patches to Clojure core.

Bio
Arthur Fücher is a Clojure developer from Brazil focused on developer experience and tooling. He organizes Clojure BR, the Brazilian Clojure community, and contributes to open source Clojure projects, including patches to Clojure core. He has spoken at Clojure/conj in 2025 and 2026 and at conferences in Brazil, and is a member of the InnerSource Commons Foundation. Outside of code, he is a Magic: The Gathering player and board game lover.


Henning Jansen

Company: Norled AS
GitHub: https://github.com/Henningzen

Why they’d like to join
I have payed a keen interest in the Clojurists Community for several years, and clearly see the benefit from this organization and the impact it has across initiatives in the continued evolving Clojure echo-system. Given my background from industrial domains, I regard my insight and expertise highly relevant.

Bio
For more than seven years now, I have been part of developing and maintaining a large-scale enterprise application for Maritime Public Transportation, developed on Clojure, ClojureScript with Datomic in the backend. During parts of this time, I have been individual contributor to Clojurists Together. I want to expand my contribution to also being of service to the board, providing industry insights and experience.


Lorelai Lyons

Company: GBS Solutions Corporation (contractor using Clojure)
GitHub: thefakelorlyons
Slack/discord: melody

Why they’d like to join
I value the mission of CT, and I value Clojure and its community. I think open source development is important and I like the idea of supporting all of these causes through CT.

Bio
My name is Lor and I have been a part of Clojurists Together for two years now (as a Board Member). I have been writing Clojure as a hobby/passion for 5-6 years and professionally for 2 years now. I am most well known (most likely) from my work on ‘brainfloj’, but I have worked on several other significant projects in clojure, and I value the Clojure community and open source development in general. In addition to Clojurists Together, I am also relatively active in Clojure Camp, and SciCloj.


Chris Oakman

GitHub: https://github.com/oakmac/
https://chrisoakman.com
https://x.com/oakmac1

Why they’d like to join
I have been involved in the Clojure community for over ten years, and I think Clojurists Together is a positive force for our ecosystem. When I received a CT grant for Standard Clojure Style in 2024, I worked harder on that project than I normally would have because I wanted to honor the community’s vote of confidence. Funding open source pays for essential maintenance and bug fixes, but that signal of recognition and support matters just as much. I would like to help CT keep doing that good work.

At the last Clojure/conj, several people encouraged me to apply for the board, and I think I’d be a good fit. I have worked on ClojureScript and developer tooling since 2012, I know the ecosystem well, and I stay in contact with people across the community. I have also served on nonprofit boards for years outside of software, so I’m comfortable with the day-to-day work of meetings, voting, and follow-through. Mostly, I want to volunteer my time to help run and promote CT, and to contribute to some of the open source projects CT supports where I can be useful.

I also want to be an optimistic voice for how our community approaches AI. Software development is changing fast, and we should embrace this technology while still holding on to the principles that make Clojure great. We should support tooling and documentation that are easy for LLMs to consume, and encourage more content showing how well Clojure works alongside modern AI tools. I understand that many developers have mixed feelings about how AI is changing the work of software, and I think it is important for the Clojure ecosystem to actively engage with the future rather than sit on the sidelines.

Bio
Chris Oakman has been writing Clojure and ClojureScript since 2012. He is the author of the ClojureScript Cheatsheet (cljs.info/cheatsheet) and Standard Clojure Style, designed the ClojureScript logo, and worked on Parinfer. Chris worked as a coding bootcamp instructor for several years, including teaching a course on ClojureScript. He cares about developer education, supporting open source work, and building the documentation and resources that help the Clojure community grow. He lives in The Woodlands, Texas, where he plays ultimate frisbee and has served for many years on the boards of two local ultimate nonprofits.


Mohit Verma

LinkedIn: https://www.linkedin.com/in/mohitverma1981

Why they’d like to join
I am inspired by Clojure Together’s mission to sustain open‑source development and empower a global community of contributors. Having led large‑scale transformation programs in the BFSI sector, I see strong parallels between building resilient financial operations and nurturing sustainable developer ecosystems. Joining the Board would allow me to contribute my governance expertise, strategic oversight, and passion for community building to ensure the initiative continues to thrive with transparency, accountability, and long‑term impact.

With over two decades of senior leadership experience—including as SVP/Director at BNY Mellon—I bring deep knowledge of compliance, operational excellence, and stakeholder engagement. My background in organizational change management and risk frameworks equips me to strengthen board governance and support strategic growth. Beyond corporate leadership, my entrepreneurial and educational pursuits reflect a commitment to collaboration and knowledge‑sharing, aligning closely with Clojure Together’s ethos. I believe this blend of global governance experience and grassroots engagement makes me a strong fit to help guide the organization’s future.

Bio
I have more than two decades in Senior Leadership roles in the BFSI industry. In my most recent experience as Senior Director at BNY Mellon. My career has focused on compliance, operational transformation, and building strong governance frameworks. Along the way, I’ve led large teams, managed complex change programs, and helped organizations stay resilient in fast‑moving environments.

Outside of corporate life, I’m exploring entrepreneurship and education—launching a home‑based food venture and building an English tutoring practice. These projects reflect my passion for community, collaboration, and knowledge‑sharing. I believe this mix of global governance experience and grassroots engagement makes me well‑suited to contribute to Clojure Together’s mission of sustaining open‑source development with transparency and long‑term impact.

Probabilistic programming for Clojure programmers: write the model once, choose the inference later

If you have used core.logic, you already know half of probabilistic programming. A logic program states constraints and lets the system search for the values that satisfy them. A probabilistic program does the same, except that a constraint can be soft: a value that fits the data poorly is not ruled out, only made less likely. Two dice show the idea below. A small forecasting model then runs under several inference methods in Foerster, an MIT-licensed probabilistic programming library for Clojure, and every result is checked against an exact answer.

Foerster continues the Anglican line of probabilistic programming in Clojure. Christian Weilbach, who wrote it, did his PhD in Frank Wood’s group at the University of British Columbia, where Anglican was built.

What is a probabilistic program?

Take two dice and the constraint that they sum to 7. Without any library, the plainest way to find the pairs that meet it is to generate pairs at random and keep the ones that pass:

Read moreShow less
Superficie Clojure What is this syntax?
defn roll []:
  inc(rand-int(6))
end

defn generate-and-test [n]:
  loop [kept []]:
    if n = count(kept):
      kept
    else:
      a := roll()
      b := roll()
      recur(if 7 = a + b:
              conj(kept, [a b])
            else:
              kept
            end)
    end
  end
end

frequencies(generate-and-test(6000))
;; each of [1 6] [2 5] [3 4] [4 3] [5 2] [6 1] about 1000 times
(defn roll [] (inc (rand-int 6)))

(defn generate-and-test [n]
  (loop [kept []]
    (if (= n (count kept))
      kept
      (let [a (roll) b (roll)]
        (recur (if (= 7 (+ a b)) (conj kept [a b]) kept))))))

(frequencies (generate-and-test 6000))
;; each of [1 6] [2 5] [3 4] [4 3] [5 2] [6 1] about 1000 times

The random choices form a generative model, a small simulator of two dice. The test conditions the simulator on what we know. What survives is the posterior: the pairs that are still possible, and how often each occurs.

A probabilistic program states the same model without fixing how to search it:

Superficie Clojure What is this syntax?
defn dice []:
  sp/spin:
    a := sample(dist/uniform-discrete(1, 7), :id, :a)
    b := sample(dist/uniform-discrete(1, 7), :id, :b)
    factor(if 7 = a + b:
      0.0
    else:
      ##-Inf
    end)
    [a b]
  end
end
(defn dice []
  (sp/spin
   (let [a (sample (dist/uniform-discrete 1 7) :id :a)
         b (sample (dist/uniform-discrete 1 7) :id :b)]
     (factor (if (= 7 (+ a b)) 0.0 ##-Inf))
     [a b])))

sample introduces an unknown, as a fresh logic variable does in core.logic, but with a probability on each value: here a die whose six faces are equally likely. factor adds to the run’s log weight. Zero leaves the run unchanged, and minus infinity makes it impossible, as a failing goal does. sp/spin turns the body into a program that an inference method can pause, copy and resume at each sample and factor. With {:method :enumerate}, Foerster runs all 36 pairs and returns the six that hold, each with probability 1/6. That is the answer run* gives, with weights.

Now soften the constraint. We do not know the sum, but a sensor that measures it with noise of about one showed 7:

Superficie Clojure What is this syntax?
observe(dist/normal(a + b, 1.0), 7.0, :id, :reading)
(observe (dist/normal (+ a b) 1.0) 7.0 :id :reading)

observe weights each run by how likely the reading is under it, here a normal density centred on the run’s sum. No pair is ruled out, and the arithmetic can be done by hand. Each of the six pairs that sum to 7 gets weight e⁰ = 1, each of the five pairs that sum to 6 or to 8 gets e^−½ ≈ 0.61, and the weights keep falling with the distance from 7. Normalized, a sum of 7 has probability 0.45, sums of 6 and 8 have 0.23 each, sums of 5 and 9 have 0.04 each, and the rest almost nothing. Foerster’s enumeration gives the same numbers.

core.logicFoerster
a fresh logic variablesample, an unknown with a probability on each value
a goal such as ==, which holds or failsobserve or factor, a weight; failing is weight zero
conde, a choice between branchesa discrete sample, with a probability on each branch
run*, the set of answersthe posterior, the answers with their weights
the search strategythe inference method

Two more ideas carry over. The total weight before normalizing, called the evidence, says how well the model as a whole explains the data. A hard constraint that no run meets leaves nothing, and a soft one leaves a number that can be compared between models. And as core.logic leaves the search to the system, a probabilistic program leaves the inference method open. ProbLog joins the two traditions as probabilistic logic programming.

How does the system find the answer?

Enumeration works for 36 pairs. It fails for many choices, or for continuous ones, because no list of all runs exists. The other methods search the space of runs, and each resembles a familiar strategy:

  • Importance sampling runs the program many times and weights each run instead of discarding it. It is generate and test with soft scores, and it fails when good runs are rare.
  • Sequential Monte Carlo (SMC) runs many copies side by side. At each observation it copies the runs that fit and drops the ones that do not, like a search that prunes partial solutions as soon as a constraint can be checked.
  • Markov chain Monte Carlo (MCMC) changes one choice of a single run at a time and keeps the change by a rule that sometimes accepts a worse run. This local search visits runs in proportion to their weight.

Does the choice of method matter?

A shop sells around a hundred units of a product each week. The weekly figure is noisy, and the underlying demand drifts. How high is demand now, and how certain is that? The model says that demand starts near 100, moves by a random amount each week, and that each week’s figure is demand plus noise:

Superficie Clojure What is this syntax?
defn demand [weeks drift]:
  sp/spin:
    loop [t 0, level sample(dist/normal(100.0, 20.0), :id, [:level 0])]:
      observe(dist/normal(level, 8.0), nth(weeks, t), :id, [:y t])
      if inc(t) = count(weeks):
        level
      else:
        recur(inc(t), sample(dist/normal(level, drift), :id, [:level inc(t)]))
      end
    end
  end
end
(defn demand [weeks drift]
  (sp/spin
   (loop [t 0
          level (sample (dist/normal 100.0 20.0) :id [:level 0])]
     (observe (dist/normal level 8.0) (nth weeks t) :id [:y t])
     (if (= (inc t) (count weeks))
       level
       (recur (inc t)
              (sample (dist/normal level drift) :id [:level (inc t)]))))))

As with the dice, sample marks the unknowns, here twenty-one weekly demand levels, and observe scores each run against the twenty sales figures. The data are an ordinary argument, weeks. The :id names each choice, so a method can refer to the level in week 7 across runs. The pictures article explains priors and posteriors if those terms are new. Each method is one option of the same call:

Superficie Clojure What is this syntax?
infer/infer(demand(weeks, 4.0), {:method :smc, :particles 1000})
infer/infer(demand(weeks, 4.0), {:method :importance, :particles 1000})
infer/infer(demand(weeks, 4.0),
  {:method :mh, :iterations 4000, :chains 4, :burn 1000})
infer/infer(demand(weeks, 4.0),
  {:method :pgibbs, :particles 50, :iterations 600})
(infer/infer (demand weeks 4.0) {:method :smc :particles 1000})
(infer/infer (demand weeks 4.0) {:method :importance :particles 1000})
(infer/infer (demand weeks 4.0) {:method :mh :iterations 4000 :chains 4 :burn 1000})
(infer/infer (demand weeks 4.0) {:method :pgibbs :particles 50 :iterations 600})

The model is linear and Gaussian, so a Kalman filter computes the exact answer, which makes it a fair test. The data are twenty simulated weeks, with a true drift of 4 per week and observation noise of 8. Each particle method ran ten times with different seeds, so the table shows how much an answer varies from run to run. All numbers come from Foerster’s main branch on 15 October 2026.

MethodFinal level (mean)Log evidenceCheck
Exact (Kalman filter)102.89−77.49
Sequential Monte Carlo, 1000 particles102.92 ± 0.23−77.44 ± 0.14about 120 histories survive to week 0
Importance sampling, 1000 samples102.9 ± 1.8−77.7 ± 0.52 to 10 effective samples
Metropolis–Hastings, 4 chains103.4R-hat 1.03, 114 effective draws
Particle Gibbs, 4 chains102.93R-hat 1.002, about 1500 effective draws

SMC lands within 0.36 of the exact final level in every run, and its log evidence averages −77.44 against the exact −77.49. It pays for its pruning on the early weeks: of a thousand particles at the end, only 114 to 134 still descend from different starting points. Importance sampling never prunes, so after twenty weeks a handful of runs carry nearly all the weight, and the estimate moves by almost two units between runs.

Metropolis–Hastings (MH), the simplest MCMC, fails here, and its diagnostics say so before any exact answer is consulted. Its four chains disagree: their rank-normalized R-hat is 1.03, above the usual threshold of 1.01, and their 12,000 draws are worth about 114 independent ones. Foerster computes these numbers as Stan and ArviZ do. The cause lies in the model. Neighbouring weeks are strongly tied, so changing one week’s level at a time barely moves the run. Andrew Gelman’s folk theorem of statistical computing, restated in the Bayesian workflow paper, says as much: when the computation struggles, look at the model.

The fix is a method that changes all weeks together. Particle Gibbs runs a whole SMC through the twenty weeks at every step, holding one surviving run fixed, and so redraws all twenty-one levels at once. Four chains of 500 steps after warm-up, with 50 particles each, reached an R-hat of 1.002 and about 1500 effective draws of the final level, counting each step’s weighted average as one draw. Their mean was 102.93 against the exact 102.89. The first week’s level, which SMC sees through only a few surviving histories, reached an R-hat of 1.002 and about 660 effective draws. Particle Gibbs took four minutes against half a minute for MH, but produced about twice as many effective draws per second. Nothing in the model changed, only the search.

Each point is one week's observed demand. The shaded band is where sequential Monte Carlo puts the hidden demand level with 95% probability after that week, and the dashed line is the exact answer, which this simple model happens to allow. The two lines nearly coincide.

What if a parameter is unknown too?

So far the drift was given as 4. In practice nobody measures how fast demand moves, so the drift becomes one more unknown with a prior:

Superficie Clojure What is this syntax?
defn demand-unknown [weeks]:
  sp/spin:
    drift := sample(dist/gamma(2.0, 2.5), :id, :drift)
    loop [t 0, level sample(dist/normal(100.0, 20.0), :id, [:level 0])]:
      observe(dist/normal(level, 8.0), nth(weeks, t), :id, [:y t])
      if inc(t) = count(weeks):
        drift
      else:
        recur(inc(t), sample(dist/normal(level, drift), :id, [:level inc(t)]))
      end
    end
  end
end

infer/infer(demand-unknown(weeks),
  {:method :pmmh, :particles 100, :iterations 2000, :params #{:drift},
   :scale 3.0, :burn 200})
(defn demand-unknown [weeks]
  (sp/spin
   (let [drift (sample (dist/gamma 2.0 2.5) :id :drift)]
     (loop [t 0
            level (sample (dist/normal 100.0 20.0) :id [:level 0])]
       (observe (dist/normal level 8.0) (nth weeks t) :id [:y t])
       (if (= (inc t) (count weeks))
         drift
         (recur (inc t)
                (sample (dist/normal level drift) :id [:level (inc t)])))))))

(infer/infer (demand-unknown weeks)
             {:method :pmmh :particles 100 :iterations 2000
              :params #{:drift} :scale 3.0 :burn 200})

Particle marginal Metropolis–Hastings (PMMH) searches over the drift alone. For each proposed drift it runs SMC over the weekly levels and accepts or rejects the drift by the evidence that SMC reports. That evidence estimate is unbiased, so the chain targets the exact posterior of the drift even though every step uses an estimate.

Twenty weeks of data narrow the drift only partly: values from below 1 to about 6 remain plausible. The histogram of PMMH draws follows the exact density, which quadrature over the Kalman filter's evidence provides for this model.

The chain’s mean drift is 2.82 against an exact 2.85, and its 90% interval runs from 0.61 to 6.09 against an exact 0.61 to 6.28. The data were simulated with a drift of 4, inside the interval but not at its centre, because twenty noisy weeks cannot pin the drift down. A forecast with one fitted drift would hide that width. The 2000 iterations took five and a half minutes on a laptop. For this linear Gaussian model, Stan or a Kalman filter inside a one-parameter chain would take seconds. PMMH is for models without a gradient to follow, models that branch, count or call a simulator.

How do I run it?

Foerster is on Clojars. With the Clojure CLI:

{:deps {org.replikativ/foerster {:mvn/version "0.1.113"}}}

A model runs inside a Spindel world, an execution context, and inference returns a spin that the REPL dereferences:

Superficie Clojure What is this syntax?
require('[org.replikativ.foerster.core :as infer :refer [sample observe factor]],
  '[org.replikativ.foerster.dist :as dist],
  '[org.replikativ.spindel.core :as sp])

def world: sp/create-execution-context()

sp/with-context world:
  @infer/infer(demand(weeks, 4.0), {:method :smc, :particles 1000})
end
(require '[org.replikativ.foerster.core :as infer :refer [sample observe factor]]
         '[org.replikativ.foerster.dist :as dist]
         '[org.replikativ.spindel.core :as sp])

(def world (sp/create-execution-context))

(sp/with-context world
  @(infer/infer (demand weeks 4.0) {:method :smc :particles 1000}))

Build the model inside with-context, as here: outside a world, the program has nowhere to run. The scripts that produced every number above are in the Foerster repository.

What can Foerster not do yet?

Foerster is beta software. Beyond the methods shown here, it implements tempered and waste-free SMC, SMC², involutive MCMC with reversible jump, parallel tempering, the No-U-Turn sampler on numerical blocks, MAP estimation, the Laplace approximation, Pathfinder, and variational inference. The methods notebook describes each one with its papers, and the gallery works through classic models against exact answers.

For a static model with continuous parameters, Stan, PyMC and NumPyro remain the mature choice. Foerster’s No-U-Turn sampler works on numerical blocks whose gradient the model supplies or Raster compiles. On one hierarchical model with 89 parameters, it matched PyMC’s warm run with a gradient compiled by Raster, and ran 1.5 times faster with a hand-written one. Its general methods over program traces are slower: single-site MH takes about twelve times as long per move as Gen, because each move replays the rest of the program, and its particle filter is about four and a half times slower than Gen’s Unfold combinator. The comparison has the details, and coming from other systems maps Stan, PyMC, Turing, Gen, Anglican and WebPPL concepts onto Foerster, including what is missing.

What comes next?

Every model so far is a pure function. Useful models often are not: a forecast that reads current stock, a simulation that writes into a scenario database, an agent that calls tools. If a thousand particles write to the same database, inference corrupts the state it reasons about, which is why Gen requires that a model change no external state.

Foerster runs each particle in a Spindel world, whose state can be copied cheaply, as the copy-on-write article describes. When SMC copies a particle, it copies the particle’s world, and when it drops a particle, it discards the world. With :world-policy :fork, every particle starts from a copy of the caller’s world, so a model can read and change the systems registered there, and nothing a particle writes reaches the caller. This costs about three times as much as running in fresh worlds. Systems that cannot be copied safely, such as live network handles, are refused before the model runs, and Markov chains run in fresh worlds only. The same mechanism makes counterfactuals direct: intervene on one choice in the world where the posterior was computed (Pearl’s do operator, as in the causal graph article) and replay the program with everything else held to what was inferred. The worlds guide covers both, and a later article shows them on a model that books orders into a forked database.

The same machinery steers processes that are not statistical models. Foerster’s steer namespace runs SMC over the steps of an agent working on a task, following twisted SMC (Zhao et al., 2024) and the SMC steering of language models (Lew et al., 2023). On short spreadsheet tasks it matched picking the best of four attempts with 17% fewer tokens, but solved nothing more. It should pay on long tasks whose steps can be checked cheaply.

For Simmis, inference over forked worlds is research rather than a product feature. It is how a governed organizational record could later answer a question such as “what happens to our cash if this order slips” with a distribution instead of a single number.

Ep 120: Data Vision

Each week, we discuss a different topic about Clojure and functional programming.

If you have a question or topic you'd like us to discuss, tweet @clojuredesign, send an email to feedback@clojuredesign.club, or join the #clojuredesign-podcast channel on the Clojurians Slack.

This week, the topic is: "ingesting, deciding, and merging". We curate data, merge it, and represent choices.

Our discussion includes:

  • Different patterns of pure models
  • Outside data vs inside data
  • Differences and modelling tension between external and internal data
  • Why pure models tend to have patterns of functions
  • Ingested model
  • Decision-making model
  • Merging model
  • Async join
  • How pure models are combined with necessary side effects
  • Using models in procedural flows and data flows
  • Real world integration examples from Kubernetes and e-sports

Selected quotes

A pure data model is data that models something in the real world and pure functions you pass that data through. That's it.

Read moreShow less

For an ingested model, you want to curate a subset of external data, or normalize it in some way, so it's useful to the rest of your application.

Two main pieces: a representation of the information that is pertinent to your application, and a set of functions that operate on that data. Those functions tend to fall into patterns.

The ingested model is a coherent window to that outside world. It's the lens through which your application sees that external data. It serves as a stabilizing force for the rest of your application.

The Kubernetes data is wide, and it's deep. It's got a lot of information. It's only rivaled by GitHub responses in its size and scope!

The decision, itself, is data. If you want to push side effects to the edges, you need to represent the result of a decision in some way.

You put the side effects on the edges. I/O on the way in. I/O on the way out. The pure models in the middle.

The code that connects the model to a side effect is as simple as taking things out of a map and passing them as arguments into a side effect function. It only does one thing. It's almost one line of code. It's so small that it's easy to understand.

Most of your mind is focused on the ingested model and the decision-making model. You're curating and representing information, and then you're reasoning about that information.

A merging model is a pure model that takes other pure models and creates a composite, or enriched, singular output. It's multiple pieces stitched together into a larger work.

Merging models together is a separate concern from the individual models. A merging model's job is to construct a cohesive view from separate, but related information.

If it's all pure, you can test it. You can make sure that it behaves the correct way in different situations.

A way to look at at different models is by responsibilities. The outside system takes responsibility for representing the data in a way that has utility for its concerns, design constraints, and realities. The ingested model's responsibility is figuring out what matters for the application. The decision-making model's responsibility is representing choices that matter and the consequences of those choices. The merging model's responsibility is about how to connect separate things into a larger whole

So each kind of model is a separate problem space, but they're all related, and it becomes clear how they are connected. By decomposing models into separate problem spaces, you end up with models that are relatively stable because they only need to change when that aspect of the problem changes. They are decoupled and orthogonal to each other because they're in different information domains, problem domains, responsibility domains.

One of the best parts of a decoupled system is that it's easy to understand one model at a time. The theme of this series is "re-entrant coding". I only want to understand the parts of the system that I have to understand to accomplish the current task. I don't want to have to understand everything altogether. By decoupling these things with clean responsibilities and boundaries, we get a system that's easy to start working with again.

Each model is visible because it takes data and produces data. You have something that can be seen, inspected, logged, recorded, and put in a test. You avoid the kind of functional composition where information goes through a bunch of internal transformations, and you can't quite get a hold of it in your head. You have a relatively shallow call stack because each model produces data. The orthogonality of the models helps keep the call stack shallow.

A nice, related set of pure models: ingesting, making choices, and merging things together.

You can make the functional jokes. I'll stick with the dysfunctional jokes.

Links

BudgetSWU: my latest pile of code

I made a little web app called BudgetSWU. It recommends decklists for a trading card game called Star Wars: Unlimited. I started playing SWU almost a year and a half ago because I had a dream I was in a mall opening a pack of Yugioh cards--another trading card game that I played as a kid--and it gave me enough nostalgia to do some googlin' and discover SWU.

As an adult I now have lots more money to buy cards than I did as a kid (yay) but much less time to play with them (boo). So I don't spend much time building my own decks; I just find decklists on the internet and buy the cards for those.

BudgetSWU is now one of those places on the internet. It gets decklists from melee.gg which is used in tournaments (large and small), including each decklist's (player's) placement in the tournament. It also gets pricing data for all the cards in those decklists from tcgcsv.com, which in turn aggregates pricing data from TCGPlayer, the ebay of trading cards (it is owned by ebay, in fact).

Read moreShow less

As a final bit of data, I compute "popularity" by taking each deck's "archetype" (the leader + base, for those in the know--except that I'm actually using just base color) and seeing how many decklists in the entire corpus have that same archetype. It's cooler to play decks that not everyone else is already playing, know what I mean?

Then BudgetSWU filters all the decklists down to a handful of recommendations by computing a Pareto Front. The idea here is that we have multiple factors with which we can rank decks (cost, performance, popularity/nicheness) and we don't necessarily know which factor is most important (if deck A costs 50% more than deck B, how much better/niche does deck A have to be for it to be a better recommendation than deck B?). But we do know that if deck A beats deck B on all three of those factors, then deck A is probably the better recommendation. So we filter out all the decks that have a clearly better alternative, and then we tell the user "here's what's left; you decide." As of writing, that ends up being less than 20 decks, which is a very manageable number for a user to scroll through.

The next cool bit is that if you make an account, you can enter what cards you already own, and then the recommendations will be computed based on the cost of only the cards you don't have yet. Entering in all your cards would be a pain since players old enough to have jobs usually have thousands (of cards, not jobs). So BudgetSWU sorts all the cards by cost, most expensive first. (Right now the most expensive cards go for about $30-ish a piece). You can start from the top and enter quantities until you got to cards that cost, say, a dollar each or whatever you feel like, which doesn't take long.

Finally: whenever a new decklist makes it into your personalized recommendations (tournaments happen every week), you get a notification email. Booyah.

I generated the code in Clojure. It uses my web framework Biff. I've recently been working on some LLM skills that provide guidance for writing Biff code that doesn't suck. And I have to say, I'm fairly pleased with the results. The code still isn't anything I would print out and frame on my wall, but the overall structure is generally inline with how you're supposed to write Biff apps. If this was an app I planned to develop further (it's not), I would be happy with the state the code is in as a starting point.

If you're into specifications, you can read the one for BudgetSWU over here. i.e. that page is the thing I actually typed, mostly.

Using AI to analyse legacy systems faster: what our pilots revealed

Futuristic in Industry 4.0 and business virtual diagram with Ai, robot assistant, Cloud, big data and automation. two colleague web designer discussing data and digital tablet docking keyboard.

AI can help organisations analyse legacy systems faster 

One of the biggest barriers to modernising legacy technology is understanding how existing systems work.   

To modernise systems, organisations first need a clear picture of how applications work, connect and depend on one another. That process can take weeks or even months. Documentation is often incomplete or out of date, knowledge may be concentrated in a small number of specialists, and some systems rely on technologies that few engineers still work with regularly. 

Our pilots indicate that AI-assisted approaches could help technical teams analyse some legacy systems more quickly. 

Across participating organisations, activities estimated to take weeks using traditional approaches were completed in hours. 

Working with organisations including the Student Loans Company (SLC), the UK Hydrographic Office (UKHO), the Met Office, and our delivery partner Kainos, we found that AI can help teams analyse unfamiliar systems, identify dependencies and build technical knowledge more quickly than traditional approaches alone. The pilots covered different systems and technical environments, so the results should not be treated as directly applicable to every legacy estate. 

Read moreShow less

However, for organisations managing large and complex technology estates, this could make some modernisation activities easier to prioritise, plan and deliver.  

What happened at the Student Loans Company 

At the Student Loans Company, teams explored how AI assisted approaches could be used to analyse an estate of 33 applications written in Clojure and support future modernisation planning. 

Using traditional approaches, the work was estimated to take around five weeks. During the pilot, teams completed the analysis in less than a day. 

The analysis gave the team a clearer view of the 33 applications and their dependencies, which it used to inform migration planning and work towards supported versions of Java. 

The most significant finding was not the reduction in effort itself, but the ability to unlock work that had previously been difficult to prioritise.  

What happened at the UK Hydrographic Office 

At the UK Hydrographic Office, teams focused on a 20-year old desktop application that previously had no automated tests. 

AI assisted techniques were used to generate automated tests for the application. Test coverage increased from 0% to 96%, while regression testing that had previously required around 15 days of manual effort was reduced to approximately 30 minutes. 

The benefits extended beyond faster testing. 

Teams reported greater confidence in making changes to the application safely and maintaining it over time. For many legacy systems, the perceived risk of change can be a barrier to improvement. The pilot indicated that AI-assisted testing could give engineers more evidence when assessing proposed changes. 

Why this matters 

The examples from SLC and UKHO illustrate a wider challenge facing organisations across the public sector. 

Legacy systems often support critical services, but analysing them can be complex and expensive. Teams may spend weeks mapping dependencies, reviewing code and building technical knowledge before they can assess risks or plan change. 

The strongest finding from the pilots was that AI can help accelerate this discovery and assessment phase. That may sound modest, but it addresses a critical point where many modernisation efforts slow down or stall. 

By helping teams understand systems more quickly, AI assisted approaches can make it easier to: 

  • assess risks 
  • identify system dependencies 
  • prioritise investment 
  • improve technical documentation 
  • develop modernisation plans 

In short, AI can help organisations spend less time understanding their systems and more time improving them. 

AI is not a substitute for good delivery 

The pilots also reinforced an important lesson. 

AI does not remove the need for good governance, engineering expertise or delivery capability. 

Successful outcomes depended on organisations having the right foundations in place, including clear ownership, access to systems and information, experienced technical teams and established delivery processes. 

In practice, the pilots showed that AI amplified existing strengths and weaknesses rather than replacing them.  

Used well, AI can accelerate analysis, improve visibility of complex systems and help organisations make progress more quickly. Used without appropriate controls, it can amplify existing problems and accelerate poor decision making. 

Where AI-assisted analysis may help 

The pilots suggest that AI can significantly reduce the effort required to assess complex systems, helping organisations move more quickly from discovery to modernisation. 

For organisations managing large and complex technology estates, this can help engineers, architects and delivery teams to identify risks earlier, making modernisation activities easier to plan, justify and deliver.  

What happens next 

We are continuing to work with organisations using AI assisted approaches to analyse, assess and modernise legacy systems, building on the lessons from these pilots. 

Alongside this work, we are developing a playbook of good practice that captures practical approaches, lessons learned and implementation considerations for organisations looking to apply AI to legacy technology challenges. The playbook is currently undergoing user testing, review and approval ahead of publication. 

Get involved 

If your organisation is exploring AI assisted approaches to analysing, documenting or modernising legacy systems, or would like to share experiences and engage with our wider Firmer Foundations programme, we would like to hear from you. Get in touch at firmer.foundations@dsit.gov.uk 

AI Is a Tool. Using It Well Takes Skill.

In his October 11, 2026 article, "'AI' is Not a Tool," Rich Hickey argues that LLMs are automation rather than tools because they cannot be wielded. I agree that outsourcing work can cost us opportunities to learn. But his conclusion does not follow. Even by his definition, an LLM can be something a human wields. And using it well takes skill, just as using other tools does.

Picking up a saw does not guarantee a straight cut. Holding a chisel does not make you a sculptor. Tools can amplify poor judgment as readily as good judgment. Their usefulness depends on the operator's ability to direct them, interpret feedback, recognize mistakes, and make corrections. Those requirements also apply to LLMs.

One of my colleagues used to say, "AI is a mirror." I find that insightful: it reflects who you are, including the curiosity, judgment, and habits you bring to it. The questions you ask and the standards you apply to its answers matter.

Read moreShow less

An LLM's unpredictability is a meaningful limitation. But predictability, reliability, simplicity, and bounded function are qualities by which we evaluate tools, not prerequisites for membership in the category. A tool does not stop being a tool because it is difficult to control or easy to misuse.

Hickey's distinction between tools and automation also creates a false division. Automation can be a feature of a tool. A compiler performs substantial work without the programmer continuously directing each operation. A spreadsheet recalculates results without someone manually carrying out every calculation. We still wield these systems by specifying inputs, examining results, and deciding what to do next. Why should generating text automatically disqualify an instrument from being a tool when translating code or calculating numbers automatically does not?

The claim that LLM use lacks intention, feedback, direction, and expertise is especially difficult to defend. A programmer can ask an LLM to explain unfamiliar code, challenge that explanation, inspect the source, test a proposed change, reject it, and refine the request. That is a feedback loop directed by a human pursuing a purpose. Doing it well requires knowing what to ask, what evidence matters, and when the answer is wrong. Expertise can make the instrument more useful.

Hickey's concern about reading is valid for a particular use: accepting a summary in place of reading a paper can sacrifice much of what reading teaches. But someone can also read the paper and use an LLM to question their interpretation, explore an objection, or identify something to examine more closely. The possibility of avoiding intellectual work does not establish that every use avoids it. The relevant question is what the person actually does.

What disappoints me the most is the reliance on the AI industry's anthropomorphic vocabulary: "go rogue," "escape containment," "conspire." Repeating those expressions risks accepting the framing that makes these systems sound like independent beings. Software can produce unexpected outputs, fail to follow instructions, or cause harm when given access to consequential actions. Those are real engineering problems. Calling them "conspiracy" adds a suggestion of human motives without establishing any.

I would not claim that an LLM always does exactly what a human tells it to do. It plainly can fail. But failure to obey perfectly does not make it cease to be a tool, any more than successful use proves that its operator has surrendered their judgment.

The useful distinction is between wielding an instrument with skill and delegating work without adequate understanding or oversight. That distinction applies to AI, and it gives us something more practical than declaring that an entire class of software cannot be a tool.

Clojure OSS update

It's been a while since I worked on my Clojure libraries, so I would like to give a little update on that. Two of them got a new release, and one got a debut release on Clojars.

clj-pnm [1]

Apart from the minor bug fixes and Clojure(Script) dependency update, the most exciting thing is that the Clojure dialect support matrix has grown. clj-pnm was built from the start as a cross-dialect library supporting both Clojure and ClojureScript. It is lightweight enough to expand that.

For the Clojure side, the library is compatible with Babashka, as it can be called from Babashka code and can be used as a Babashka dependency.

On the ClojureScript side, clj-pnm can target NodeJS and the browser as before. Switching from figwheel to shadow-cljs for the testing framework was integral in ensuring that. There is a special test runner for testing Squint compatibility, but with no code changes, this lightweight dialect of ClojureScript is also added to the compatibility matrix.

There are tests for each of these in the library repository[2].

cljs-map-pins [3]

cljs-map-pins got a maintenance release, so no new features yet. But this post can serve as a reminder of how fun it is to use this library (at least for me :-D). I love visualizing things on a map, and this library is made just for that. Two examples I like to refer to, albeit they are made with a much older version of the library, are The Warriors path through New York City[4] and Big Wayne tribute[5].

See the repository[6] for more details.

defns [7]

defns is a proof-of-concept library that provides a way for defining functions with type signatures for which :pre and :post conditions are generated. This is an experimental idea and should not be used for anything serious, but I've seen some interest in it and decided to release it to Clojars. The fun part was writing the library clj-kondo configuration and custom hooks for the macros. When using defns as a dependency, make sure to import the clj-kondo configuration for the linting to work.

See the repository[8] for more details.

Enjoy!

[1] clj-pnm

[2] clj-pnm repository

[3] clj-map-pins

[4] The Warriors path through New York City

[5] Big Wayne tribute

[6] clj-map-pins repository

[7] defns

[8] defns repository

Week Notes 2026.41

Babashka tasks for deploying Datomic Cloud ions; making AWS API Gateway requests with SigV4 authentication.

Clojure 1.13.0-alpha9

Clojure 1.13.0-alpha9 is now available! Find download and usage information on the Downloads page.

  • Dropped :defaults directive - use cases better handled by others now

  • CLJ-2983 Removed :defaults tests

  • Faster transient propagation of metadata in into and update-vals

  • Revise impl and inline of meta via RT

  • CLJ-2978 Catch protocol extension of non-protocol methods

  • CLJ-2982 TransientArrayMap should expand to same threshold as PersistentArrayMap

  • Favor class files when reproducible build sets class and source timestamps the same

  • CLJ-2984 Set file timestamps to last git commit time

Try it out

Update your deps.edn :deps with:

org.clojure/clojure {:mvn/version "1.13.0-alpha9"}

Start a REPL with the Clojure CLI (any version) with:

clj -Sdeps '{:deps {org.clojure/clojure {:mvn/version "1.13.0-alpha9"}}}'