Cover illustration

TheDaily Front

Issue No. 7 Thursday, July 16 2026 #7 — THURSDAY, JULY 16, 2026
Open weights, closed libraries, sharp laptops, and one newly discovered monkey.
Thursday, July 16, 2026 The Daily Front No. 7 — Contents
30stories
11,227points
5,940comments
331kllm tokens
Assembled with 31 model calls — 238,618 tokens read, 92,459 written.

Highlights

Kimi K3: Open Frontier Intelligence

Kimi K3 arrives as an open 3T-class model, bringing frontier-scale ambition and a million-token context window to the public square.

How Our Rust-to-Zig Rewrite Is Going

The Roc compiler team reports from the trenches after rewriting 300,000 lines from Rust to Zig.

The lost joy of music piracy

A meditation on What.CD, Oink, and streaming’s bland convenience rekindles old arguments about music, ownership, and discovery.

Sony deletes more movies from the accounts of people who ‘bought’ them

Sony’s latest movie deletions remind buyers that a digital purchase can still vanish like yesterday’s rain.

Microsoft Comic Chat is now open source

Microsoft Comic Chat returns as open source, dragging Comic Sans, IRC nostalgia, and 1990s whimsy back into daylight.

From the Editor

The presses groaned under the weight of artificial intelligence today, with open models, local agents, detectors, and creative contraptions all demanding the banner. Yet the old questions remain as lively as ever: who owns the tools, who owns the media, and who gets left holding the empty sleeve when the cloud changes its mind?

  1. Kimi K3: Open Frontier Intelligence3
  2. How Our Rust-to-Zig Rewrite Is Going4
  3. The lost joy of music piracy5
  4. Sony deletes more movies from the accounts of people who ‘bought’ them6
  5. Microsoft Comic Chat is now open source7
  6. Decoy Font8
  7. Detecting LLM-Generated Texts with “Classical” Machine Learning9
  8. The LLM Critics Are Right. I Use LLMs Anyway10
  9. $100 AI Music Video: Claude Fable 5 vs. GPT-5.6 Sol11
  10. LM Studio Bionic: the AI agent for open models12
  11. Command Line Interface Guidelines13
  12. If you want to create a button from scratch, you must first create the universe14
  13. Job queues are deceptively tricky15
  14. LLM Networking with MikroTik16
  15. Teardown: A Generic 7-Port USB 3.0 Hub That Wasn't17
  16. Bluesky Trademarks ATProto18
  17. I also filed the corners off my MacBook19
  18. Show HN: Clx – Compile Lua to Native Executables Through C++2020
  19. Helium escaping from atmosphere of nearby rocky exoplanet in a habitable zone21
  20. 'Likweli': A new monkey species discovered in the Congo Basin22
  21. NotebookLM is now Gemini Notebook23
  22. OnePlus halts operations in USA and Europe23
  23. Ente – Opening Our Books23
  24. Immersive Linear Algebra Book with Interactive Figures (2015)24
  25. The Little Book of Reinforcement Learning24
  26. Mathematics of Data Science24
  27. How to Train a Gen AI Kick Drum Model on Your Old Linux Desktop with 6GB VRAM25
  28. CD sales growth outpaced vinyl in the first half of 202625
  29. 1,300 Beautiful Wildlife Illustrations from the 19th Century Now Restored25
  30. Goes-19 weather satellite enters Safe Hold mode25
The Daily Front Page 2 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Lead: Frontier in the Open
article

Kimi K3: Open Frontier Intelligence

by vincent_s·▲ 2,012 points·1,175 comments·kimi.com ↗
It is the world's first open 3T-class model.

Kimi K3 hero visual

Today, we are introducing Kimi K3 — our most capable model. Kimi K3 is a 2.8T-parameter model built on our Kimi Delta Attention and Attention Residuals, with native vision capabilities and a 1-million-token context window. It is the world's first open 3T-class model, designed for frontier intelligence across long-horizon coding, knowledge work, and reasoning.

While its overall performance still trails the most powerful proprietary models, Claude Fable 5 and GPT 5.6 Sol, Kimi K3 demonstrated frontier-level performance across our evaluation suite, consistently outperforming other tested models.

Kimi K3 benchmark comparison

Kimi K3 benchmark comparison

Kimi K3 showcase

Kimi K3 showcase

Kimi K3 is available today on Kimi.com, Kimi Work, Kimi Code, and the Kimi API. At launch, Kimi K3 will use max thinking effort by default, with low- and high-effort modes to be introduced in subsequent updates. We are currently working closely with inference partners and open-source maintainers to align technical details and ensure a reliable rollout across the ecosystem. The full model weights will be released by July 27, 2026. Further details on the architecture, training, and evaluations will be released alongside the Kimi K3 technical report.

An Open 3T-Class Model

Kimi K3 is the first open model to reach 2.8 trillion parameters. It marks the latest step in Kimi's sustained push at the scaling frontier: for nine of the past twelve months, Kimi models have set the upper bound of open-model sizes.

Kimi K3 is built on Kimi Delta Attention (KDA) and Attention Residuals (AttnRes), two architectural updates designed to improve how information flows across sequence length and model depth. We have also scaled up Mixture of Experts (MoE) sparsity, effectively activating 16 out of 896 experts when paired with a Stable LatentMoE framework. Together with refined training and data recipes, these structural changes yield an approximate 2.5× improvement in overall scaling efficiency compared to Kimi K2, allowing the model to convert compute into intelligence more effectively.

Coding

Kimi K3 has strong long-horizon coding performance. Operating with minimal human oversight, it can sustain long engineering sessions, navigate massive repositories, and orchestrate terminal tools.

Kimi K3 also excels in tasks blending software engineering with visual reasoning — it leverages screenshots and visuals to optimize game dev, frontend, and CAD.

The case studies below show how Kimi K3's coding capability translates into open-ended software creation and scientific research.

Kernel Optimization

We tested the models' capability to optimize GPU kernels. Each model works independently in an identical sandbox, with up to 24 hours to profile, rewrite, and benchmark four tasks spanning AttnRes, KDA, and a 512-head-dimension MLA kernel across NVIDIA H200 and GPGPU from an alternative vendor. Kimi K3 performed competitively with Fable 5 (with fallback) and substantially outperformed Opus 4.8, GPT 5.6 Sol, and GPT 5.5.

Claude Fable 5 was evaluated by a third party, and its results may include fallback behavior. Across most models, some trajectories include small, acceptable precision shortcuts that remain within our numerical tolerance. GPGPU denotes general-purpose GPUs used for computation beyond graphics rendering.

In the late stages of Kimi K3 development, an early version of Kimi K3 handled the majority of the team's kernel optimization works.

GPU Compiler Development

We further tested whether Kimi K3 could build a GPU programming system from scratch. Kimi K3 developed MiniTriton, a compact Triton-like compiler with its own tile-level IR layer over MLIR, optimization passes, and a PTX code-generation pipeline. Across supported roofline benchmarks, MiniTriton delivers performance on par with or better than Triton and torch.compile — beating Triton on certain workloads. Beyond microbenchmarks, MiniTriton sustains end-to-end nanoGPT training with stable convergence, the loss curve closely tracking the reference with only minor divergence — validating the full pipeline on a realistic workload. These results demonstrate that Kimi K3 can build a coherent end-to-end compiler — from DSL frontend and IR passes to PTX codegen and runtime — rather than isolated kernels; its from-scratch Tensor Core path already rivals Triton’s extensively optimized stack.

MiniTriton CUDA-core roofline on NVIDIA L20

Game Dev and Digital Creation

Kimi K3 combines strong 3D reasoning, coding, and vision capabilities to turn concepts, images, and videos into fully playable interactive experiences. Kimi K3 achieves true "vision in the loop" by seamlessly iterating between code and live screenshots—instantly seeing and refining outputs.

Chip Design

As an early proof of concept, Kimi K3 designed a chip to serve a nano model built on its own architecture. In a single 48-hour autonomous run, K3 built, optimized, and verified the chip using open-source EDA tools on the Nangate 45nm library. Within 4 mm², the chip closes timing at 100 MHz and sustains over 8,700 tokens/s decode throughput in simulation, packing 1.46M standard cells, 0.277 MB of SRAM, and an INT4 MAC array with fused dequantization. A chip built by a model, for a model, reflects K3's long-horizon agentic capabilities.

Coding for Research

Kimi K3 bridges scientific literature and executable code, autonomously implementing, validating, and analyzing complex computational research workflows.

In one case, Kimi K3 completed in about two hours what would typically require one to two weeks of work by an experienced researcher. To reproduce the I–Love–Q universal relations in computational astrophysics, it reviewed and cross-validated 20+ papers, implemented the full numerical pipeline, evaluated 300+ equations of state, identified inconsistencies in published formulas, generated 3,000+ lines of Python code, and produced an interactive HTML dashboard for exploring the results.

Knowledge Work

Kimi K3 advances end-to-end knowledge work. Beyond public benchmarks, Kimi K3 (max) demonstrates consistent gains across our internal evaluations, which are derived from recurring patterns and challenges observed in real-world user-agent workflows. These consistent advantages across distinct production-oriented workflows reflect a broad improvement in Kimi K3's agentic knowledge work capabilities.

Internal Knowledge Work Bench

Research with Interactive Visualization

Below are a few examples of what Kimi K3 in Kimi Work can produce across financial consulting and scientific research:

Case 1: Interactive 42 years of AI ASIC industry research website

An interactive research report you can drill into: 42 years of the ASIC industry, created through 120+ rounds of recursive self-improvement. Kimi K3 transforms evidence into bespoke charts, animated diagrams, and interactive visual narratives. It pulled data via 2.8k+ web searches/fetches and 1.1k+ terminal data pulls, across 11k+ pages spanning 87 quarterly reports and 99 original PDFs.

Case 2: Fusion Industry Research

A consulting-style industry report with interactive visualizations—including timelines, Funnel Chart, Range Bar Chart, Gantt Charts, and publication-quality slides.

Case 3: GWTC-5 Gravitational-wave Analysis

An analysis of 391 gravitational-wave events using 20+ concurrent subagents, producing 7 scientific visualizations, 2 tables, and a literature synthesis from 10+ papers.

Kimi K3 is also particularly effective at producing infographic-style presentations, such as the fully editable heatmap and annual report shown below:

Widgets and Dashboard

In Kimi Work, we introduce two new features - Widgets and Dashboard - which make interactions with Kimi K3 more visual and persistent. Widgets let you generate interactive components directly within a chat, with connections to local data or external plugins for continuous updates. Dashboard brings the widgets you care about most into one persistent, personalized view organized around a topic, project, or goal.

Video Editing

Kimi K3 excels at motion design, animation, and video editing because its native multimodal architecture understands text, images, and video within the same model.

In one example, K3 created a 3Blue1Brown-style motion-graphics explainer of its own architecture, translating technical ideas into animated diagrams and transitions.

In another, Kimi K3 edited its own teaser video from 56 source clips, handling clip selection, motion-matched cuts, frame-accurate beat synchronization, audio processing, and multiple rounds of revision. A high-density short video like this would typically take an experienced editor one to two working days, or a beginner three to five.

Architecture and Infrastructure

Kimi K3 is built on Kimi Delta Attention (KDA) and Attention Residuals (AttnRes). KDA provides an efficient foundation for scaling attention, while AttnRes selectively retrieves representations across depth rather than accumulating them uniformly. Together, they form the architectural backbone of a model designed to scale well beyond the trillion-parameter regime.

Kimi K3 uses Stable LatentMoE, effectively activating 16 of 896 experts. At this level of sparsity, routing and optimization become first-order challenges. Quantile Balancing derives expert allocation directly from router-score quantiles, eliminating heuristic updates and a sensitive balancing hyperparameter, while Per-Head Muon extends Muon by optimizing attention heads independently for more adaptive learning at scale. Sigmoid Tanh Unit (SiTU) and Gated MLA improve activation control and attention selectivity respectively. Together, these advances enable stable and efficient training at the 2.8-trillion-parameter scale.

Kimi K3 applies quantization-aware training from the SFT stage onward, using MXFP4 weights with MXFP8 activations for broad hardware compatibility. To prevent expert imbalance from degrading throughput at large expert-parallel scales, we introduce a fully balanced expert-parallel training method with static shapes and no host synchronization on the critical path. Since inference efficiency likewise benefits from larger high-bandwidth communication domains, we recommend deploying Kimi K3 on supernode configurations with 64 or more accelerators. Finally, as KDA poses new challenges for conventional prefix caching, we have contributed a corresponding implementation to the vLLM community, to be released alongside the model. KDA with prefill cache allows us to serve Kimi K3 at a highly competitive token price despite its scale and long context.

More technical details will be available in our coming report.

Availability

  • Kimi K3 Agents: Download or update to the latest Kimi app from your mobile app store, available on iOS, Android, and HarmonyOS, or visit kimi.com.
  • Work with Kimi K3: Download the latest Kimi Work desktop app, version 3.1.0 or later, available for Windows and Apple silicon Macs.
  • Code with Kimi K3: Run Kimi Code in your terminal and select Kimi K3 using the /model command.
  • Build with the Kimi API: Visit the Kimi API Platform and select kimi-k3. Pricing is $0.30/MTok for cache-hit input, $3.00/MTok for cache-miss input, and $15.00/MTok for output. Powered by Mooncake's disaggregated inference architecture, the official Kimi API achieves a cache hit rate above 90% in coding workloads.
  • Bring Kimi to your organization: Kimi Enterprise provides enterprise-grade data privacy and member management, with complete separation between personal and organization accounts. Visit the pricing page and select “Get Kimi Enterprise” to subscribe for your team.

Full Benchmark Table

Footnotes

All Kimi K3 results reported below are obtained with the reasoning effort set to 'max', setting temperature = 1.0 and top-p = 1.0. Depending on the benchmark, each model is evaluated under one of three agentic harnesses — KimiCode, Claude Code, or Codex — as specified in the notes below.

Coding benchmarks

  1. DeepSWE. Kimi K3 is evaluated with the KimiCode harness. The GLM-5.2 score is taken from the GLM-5.2 release blog (https://z.ai/blog/glm-5.2); all remaining scores are from the official DeepSWE leaderboard (https://deepswe.datacurve.ai/), under which Kimi K3 attains 67.3 with the mini-SWE-agent harness.
  2. Terminal-Bench 2.1. Kimi K3 is evaluated with the KimiCode harness. For all other models, we report the best score across harnesses: GLM-5.2 with Claude Code (https://z.ai/blog/glm-5.2); Claude Opus 4.8 and Claude Fable 5 with Terminus 2 (https://artificialanalysis.ai/evaluations/terminalbench-v2-1); GPT 5.5 and GPT 5.6 Sol with Codex (https://openai.com/index/previewing-gpt-5-6-sol/).
  3. Program Bench. Kimi K3 is evaluated with the KimiCode harness. The GLM-5.2 score is from https://z.ai/blog/glm-5.2; all other scores are from https://www.vals.ai/benchmarks/programbench.
  4. SWE Marathon. Kimi K3, Claude Opus 4.8, and Claude Fable 5 are evaluated with the Claude Code harness; GPT 5.6 Sol is evaluated with the Codex harness. The GLM-5.2 score is from https://z.ai/blog/glm-5.2.
  5. FrontierSWE. Kimi K3 is evaluated with the KimiCode harness and GPT 5.6 Sol with the Codex harness; all other results are from https://www.frontierswe.com/. Dominance scores are recomputed from the raw scores using the official evaluation script and are current as of July 16, 2026.
  6. PostTrain Bench. Scores for GLM-5.2, GPT 5.5, and Claude Opus 4.8 are adopted from the official PostTrainBench results. Kimi K3, Claude Fable 5, and GPT 5.6 Sol are evaluated with the official Harbor implementation at maximum reasoning effort, averaged over three runs — Kimi K3 and Claude Fable 5 with the Claude Code harness, and GPT 5.6 Sol with the Codex harness. Under the Claude Code harness, requests refused by Claude Fable 5 due to its usage policy automatically fall back to Claude Opus 4.8.
  7. MLS Bench Lite. Kimi K3 is evaluated with the KimiCode harness; GLM-5.2 and the Claude models with the Claude Code harness; GPT 5.5 and GPT 5.6 Sol with the Codex harness.
  8. KCB 2.0. Kimi K3 is evaluated with both the KimiCode and Claude Code harnesses; GLM-5.2, Claude Opus 4.8, and Claude Fable 5 with the Claude Code harness; GPT 5.5 and GPT 5.6 Sol with the Codex harness. All models are evaluated at maximum reasoning effort, except GPT 5.5, which uses the "xhigh" setting.

Productivity and agentic benchmarks

  1. For OfficeQA Pro, each test case provides the agent with the entire PDF corpus, with all PDFs rendered as images and no machine-readable text available.
  2. OfficeQA Pro and SpreadsheetBench 2. Kimi K3, GLM-5.2, Claude Opus 4.8, and Claude Fable 5 are evaluated with the Claude Code harness; GPT 5.5 and GPT 5.6 Sol are evaluated with the Codex harness.
  3. MCP Atlas. All models are evaluated on the 500-task public subset with a 100-turn limit, using Gemini 3.1 Pro as the judge.
  4. AutomationBench. All models are evaluated on the 600-task public subset, following the official GitHub setup in all other respects.
  5. BrowseComp. We adopt the context-compaction strategy used in the Claude model cards, triggered at 300K tokens. When evaluated with a 1M-token context window and no context management, Kimi K3 achieves a score of 90.4. The results of Claude Fable 5, Claude Opus 4.8, GPT 5.6 Sol, and GPT 5.5 are cited from https://www.anthropic.com/news/claude-fable-5-mythos-5 and https://openai.com/index/gpt-5-6/.
  6. GDPval-AA v2 and AA-Briefcase scores are cited from https://artificialanalysis.ai/.

Multimodal benchmarks

  1. Except for ZeroBench, which follows the official setting and is run five times, all multimodal scores are averaged over three runs. MMMU-Pro is evaluated following the official protocol, preserving the original input order and prepending images to the text input.
  2. PerceptionBench. PerceptionBench is an in-house benchmark that focuses on atomic visual perception capabilities.

Limitations

  1. Sensitivity to thinking history. K3 was trained in the preserved thinking history mode. If the agent harness fails to pass back all the historical thinking content as required, or if an ongoing session with another model is switched over to K3, generation quality may become highly unstable. We recommend using a harness with verified compatibility, such as Kimi Code, and avoiding switching to K3 in the middle of a session.
  2. Excessive proactiveness. K3's training places particular emphasis on long-horizon, challenging tasks. As a result, when it encounters minor issues or ambiguous user intent during task execution, it may make unexpected decisions on the user's behalf. If your application requires the agent to operate within well-defined boundaries and refrain from excessive improvisation, please impose more explicit behavioral constraints on K3 in the system prompt or in AGENTS.md.
  3. Despite being a highly competitive model overall, K3 nonetheless exhibits a noticeable gap in user experience compared with Claude Fable 5 and GPT 5.6 Sol.
The Daily Front Page 3 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — The Rewrite Desk
article

How Our Rust-to-Zig Rewrite Is Going

by jorangreef·▲ 520 points·298 comments·rtfeldman.com ↗
We recently passed an exciting milestone: feature parity with the original compiler!

For the past year and a half, the team building Roc's compiler has been rewriting our 300,000 lines of Rust code into Zig, for reasons I'll recap below. We recently passed an exciting milestone: feature parity with the original compiler!

Since the Bun project recently shared an experience report of their rewrite in the other direction (from Zig to Rust, although that's only the tip of the iceberg of differences between our rewrites), this seems like a nice time to reflect on how our move from Rust to Zig is going.

Passing Feature Parity

Hitting this milestone made it possible to update Brendan Hansknecht's charming 2024 WASM-4 game, Rocci Bird (with art by Luke DeVault) to use the new compiler. It's a nice example because the whole game is under a thousand lines of Roc code, and you can play it on itch.io or right here via WebAssembly:

Click or tap the game, then press Space (or tap) to flap. On mobile you don't have a right arrow key, so refresh the page to restart the game.

Rocci Bird's updated source code is a bit more concise than the original, and roc build --opt=size now outputs a 31KB wasm binary. (The original compiler produced a binary more than double that size.) Rocci Bird is by no means a large code base, but getting it to run at all required landing a lot of features in the new compiler. Seeing those chunky purple pixels brought a smile to my face when we finally got there!

To be clear, this is a milestone but not a formal release. (We aim to land version 0.1.0 later this year.) That said, it's a wonderful milestone to have reached, and I'm extremely grateful to all the people who came together to make this happen! I want to thank some in particular who have been especially helpful in getting the language and compiler to this point:

  • Anthony Bullard and Sam Mohr for collaborating on the new parser
  • Jared Ramirez for the new type-checker (among many other things!)
  • Ayaz Hafiz for the new lambda set resolution system, plus tons of the original compiler
  • Aurélien Geron for hand-updating 108 (!) beginner exercises in the Roc Exercism course he originally created
  • Stephan for getting the compiler's new "echo" platform running in the browser, so that anyone can now write and run basic Roc programs from the roc-lang.org homepage via a 2.5MB WebAssembly binary!
  • Niclas Åhdén, Roc's most prolific production user, for patiently filing helpful bug reports and giving actionable feedback about the upgrade process
  • JRI98 for methodically reproducing and investigating fuzzer errors and other bugs, closing out issues that no longer reproduced, and more
  • Jasper Woudenberg for iterating on API designs for userspace packages using the new compiler
  • Folkert de Vries, Brendan Hansknecht, Brian Carroll, Josh Warner, Agus Zubiaga, and Jelle Teeuwissen for building the foundation of the original compiler, without which the new compiler never would have existed
  • I've saved the undisputed biggest contributors to the new compiler for last: Anton-4 and Luke Boswell for so many things I can't even keep track of them all—compiler work, builtins, platforms, packages, examples, fixing bugs, helping beginners on Roc Zulip…enumerating it all could take up a whole second post! It's been incredible seeing how much you've built.

Thank you all so much! I feel honored that you've put so much of your valuable time into this project. Also thanks to our past and present sponsors—rwx, Lambda Class, ohne-makler, martian, tweede golf, Vendr, NoRedInk, and many generous individual sponsors—who have helped get us to this point by supporting our contributors.

Speaking of time: our 487-day rewrite took 476 days longer than Bun's 11-day rewrite from their ~500K lines of Zig into Rust. There are many reasons for this difference which have nothing to do with Rust or Zig, including the fact that theirs was a direct port whereas we'd decided to rewrite because of how much we were going to change. The techniques they used wouldn't have worked in our case.

The laundry list of changes we made also means comparing our original Rust code base and new Zig code base won't be apples-to-apples. Still, we've reached a nice point to reflect on how the rewrite has gone, both in terms of what new features it has unlocked for Roc programmers, as well as how our experiences with Rust and Zig have compared.

Let's get into it!

Hot Code Loading + Cross-Compiled Binaries

Roc's new compiler automatically does hot code loading during development. For example, I can run roc server.roc to start a Web server, then change some of its code while it's running. The next time that server handles a request, it'll automatically be handled using the new code. Here it is in action, both in a server and in a simple 2D game:

Download the hot-loading demo video.

Hot loading is standard behavior for interpreted languages like Python, but not so much for high-performance compiled languages like Roc. When I'm ready to deploy, roc build server.roc gets me an LLVM-optimized, self-contained binary that I can drop onto a machine and run.

Roc also cross-compiles; building a static binary that runs on Alpine Linux is as simple as roc build --target=x64musl, and that command will produce the same output bytes (for the same input source code bytes) when run on a Mac or any other system—which not all compilers guarantee.

Pattern Matching with String Interpolation

The HTTP request-handling logic from that video looks like this:

match (verb, path) {
    ("GET", "/users/${id}/${page}") => match page {
        "" | "profile" => ok(id)
        "settings" => ok(with_default(user_agent, id))
        "posts/${post_id}" => ok("Post ID: ${post_id}")
        _ => not_found
    }

    ("GET", "/users/${id}") => ok(id)

    ("POST", "/posts/new") => created(with_default(…))

    _ => not_found
}

This uses several features we introduced in the new compiler. For example, that "/users/${id}" syntax is not implemented with parsing template strings at runtime, but rather with a new language feature: string interpolation inside pattern matching.

Not only is this type-safe at compile time, this entire code snippet performs zero heap allocations. I'd expect the typical language that ships with hot code loading to average closer to 1 allocation per line of code here…but Roc is aiming high on ergonomics, type safety, and performance!

You can play around with this syntax on the new roc-lang.org homepage - if you scroll down a bit, there's an WebAssembly build of the compiler right there on the page that you can use to try out the language.

By the way, if you're interested in a post on the technical details of how we used the new compiler's compile-time execution of pure functions to get HTTP request routing down to zero allocations, let me know on Roc Zulip.

Why a Scratch-Rewrite?

Unlike Rust, C, and Zig, Roc is not a systems language; it has automatic memory management (using reference counting, both to avoid tracing collector pauses and also for Perceus optimizations and opportunistic mutation like Koka's). Roc would have way more heap allocations if it needed one heap allocation per closure capture (like most non-systems languages do), but our closure captures don't heap-allocate because Roc is the first non-academic language to implement polymorphic defunctionalization through lambda set specialization.

This might sound like a niche optimization, but in a functional language like Roc, defunctionalization turns out to be similar to inlining in that it unlocks a treasure trove of follow-up optimizations. Although this system proved incredibly beneficial to Roc's runtime performance, it also proved incredibly difficult for us to implement correctly. We struggled with nasty bugs in the original implementation, and only after Ayaz Hafiz prototyped a new architecture in OCaml were we able to finally get it right in the new compiler.

Ayaz's prototype showed that the root of our problems was architectural across several compiler phases, and fixing it would require rewriting most of the compiler. This was one reason we decided to rewrite in the first place—that, and several contributors independently mentioning they planned to rewrite various parts of the compiler for other reasons. We realized we were about to rewrite almost all of the compiler anyway, so it made sense to consider a full rewrite as an alternative to the Ship of Theseus approach.

Compilers are unusual in that scratch-rewrites are the norm among successful projects. It's often the only way to self-host, although not all compilers rewrite into their own language; see for example TypeScript's rewrite to Go. My position has always been that Roc's compiler should not self-host, so the idea that someday the benefits of a rewrite might seem to outweigh their notorious costs had frankly never occurred to me.

The more we talked about it, the more sense it made to do what basically every mainstream compiler today has done at some point: rewrite from scratch.

Why Zig?

Once we'd decided to scratch-rewrite, the next question was whether to choose Rust again. Based on our experiences with both Rust and Zig (we were already using Zig for a bunch of primitives in our standard library), we decided to build the entire compiler in Zig this time.

I enjoy Rust, I've taught a course on it, and I happily use it daily for my work at Zed. Despite what Internet comments might have us believe, it's extremely normal for one language to be the best fit for one project, while a different language turns out to be the best fit for a different project. One size does not actually fit all!

I've talked in depth about our reasons for going with Zig elsewhere—in writing, on podcasts, and so on—and we only seriously considered Rust and Zig, because those were the only systems languages our team knew well enough. The biggest considerations on our minds when deciding between Rust and Zig were:

  • Build times. Our cargo build times were a major pain point, even for incremental builds, and getting worse as our code base grew. We expected build times in a Zig rewrite to be much faster.
  • Memory control. We use a variety of different memory allocators throughout compilation, especially arenas, and struct-of-arrays layouts all over the place. Rust's ecosystem consistently assumes one global allocator, including soa_rs. Zig's whole ecosystem assumes granular allocators, and struct-of-arrays support is standard.
  • Ecosystem relevance. Rust's ecosystem is much bigger than Zig's overall…but almost no packages in either ecosystem are relevant to our particular needs. For the niche things we wanted to get off the shelf—such as a faster way to emit LLVM bitcode than wrapping LLVM's C++ library—more of that code existed in Zig than in Rust.
  • Memory-unsafety assistance. Rust is designed to isolate memory-unsafe code inside rare unsafe blocks, and use things like miri or Valgrind to vet those. Memory-unsafe code wasn't rare for us, though (more on this later) and we ended up with about 1,200 uses of unsafe (out of our 300K lines of Rust code; compare to about 40,000 uses of unsafe in rust's 3.5M lines, and remember that for compilers which emit machine code, like roc and rustc, doing memory-unsafe things is a big part of the job). Zig has more features than Rust for making memory-unsafe code work correctly, and that was the area where we wanted the most help.

After a year and a half of rewriting, how did our expectations of Zig's benefits line up with the reality of what we got? And which parts of Rust did we end up missing once we no longer had access to them?

Life Without Borrow-Checking

Let's start with memory safety. There's a famous 2019 Microsoft presentation that says, on slide 10:

~70% of the vulnerabilities addressed through a security update each year continue to be memory safety issues.

The presentation's next slide has a breakdown by type of memory safety issue, which paints the following picture when it comes to Rust and Zig specifically:

  • 83.6% of vulnerabilities addressed through a security update in 2018 would have been completely unaffected by the choice of Rust or Zig, because both languages handle all of these scenarios (out-of-bounds reads/write, unsafe casts, uninitialized reads, stack overflows, and non-memory-safety issues) in the same way.
  • 16.4% of the vulnerabilities were specifically use-after-free errors. These could have been caught by Zig's ReleaseSafe runtime memory-safety checks, or Rust's borrow checker, or the checks Fil-C uses...modern languages have a variety of ways to help catch UAFs, although these CVEs from 2018 would have almost certainly been from C or C++ code instead.

ReleaseSafe catches use-after-free errors through runtime checks which panic if the program tries to use freed memory. Compared to Rust's safe subset, Zig's checks are less comprehensive, have a runtime cost, and can panic. That said, Zig with ReleaseSafe has worked great in practice for the TigerBeetle database, which recently underwent a legendarily meticulous Jepsen report that found only two safety bugs, neither related to memory safety.

ReleaseFast skips these checks in production builds to avoid their overhead, but keeps them in debug builds and tests to catch memory-safety issues during development. If your tests covered every possible real-world code path, ReleaseFast would give you the same safety as ReleaseSafe, but that level of test coverage is rarely practical; the real question is what slips through the coverage cracks in practice. Bun talked about their struggles with use-after-frees, but other widely-used projects building with ReleaseFast have had no CVEs caused by memory unsafety in their Zig code. Ghostty is one, and Zig's compiler itself is another.

If you want to learn more about these projects, I've recorded in-depth conversations with their creators: Joran Greef on TigerBeetle, Mitchell Hashimoto on Ghostty, and Andrew Kelley on Zig.

Rust code has a different source of memory-safety gaps: the unsafe sections that nearly every Rust program has somewhere in its dependencies. Unsafe Rust has all the memory unsafety risk of ReleaseFast Zig code, but none of the runtime checks to catch issues during development. The Rust ecosytsem has miri to find bugs in non-FFI unsafe code, and Valgrind can help too, but few Rust projects use either. That said, the cultural norm of using unsafe rarely, and auditing it extra carefully, has worked out well enough to earn Rust a strong reputation for memory safety in practice.

Of course, Rust memory unsafety errors can and do still slip through the cracks. Deno, a Bun competitor which is written in Rust, has had memory-unsafety CVEs including an out-of-bounds read as well as a use-after-free, both involving the use of Unsafe Rust. Rocket, a Rust Web Framework, has had a use-after-free CVE, and Actix has had a variety of memory-unsafety CVEs from a period when its use of unsafe was abnormally high.

When we were deciding between Rust and Zig for the new compiler, we were aware of all of this. We knew Rust had a well-deserved reputation for memory safety, but that memory unsafety could still happen, and we'd experienced all of that firsthand with the original compiler. We also knew we'd be using unsafe way more than typical Rust projects, and even though we were already using Valgrind, getting help with innately memory-unsafe code from Zig's additional checks sounded appealing. We wanted the hard stuff to get easier, and we weren't worried about use-after-free issues in a compiler where allocations would be overwhelmingly done in arenas with straightforward lifetimes.

We knew high-profile Zig projects had achieved great performance and memory safety in practice, and we decided to aim for becoming another of those success stories.

Memory Safety Post-Rewrite

It's easy to theorize about how things will go with a particular technology choice, but where the rubber meets the road is what end users encounter in real-world usage. So how has Zig with ReleaseFast worked out for us in practice? How many memory corruption incidents—from use-after-frees or any other cause—have we seen since rewriting our compiler from Rust to Zig?

Here's a breakdown of bug reports in Roc's issue tracker, as classified by Claude Opus 4.8:

Type of bug in Roc's compiler Rust Zig Bug where memory corruption occurred 21 10 Bug where no memory corruption occurred 2,575 421 Total 2,596 431

You might be wondering how the Rust-based compiler had any memory corruption bugs at all, let alone more than double the total count of the Zig-based one. Is it because of that pesky Unsafe Rust again?

Actually, no. None of those 21 memory corruption bugs occurred in the compiler's logic itself, which is a testament to Rust's borrow-checker working as intended. The reason we had memory corruption bugs in our Rust-based compiler is that it's a compiler.

Compilers emit machine instructions. When a machine executes those instructions, they can cause memory corruption, resulting in memory corruption bug reports from the people who experienced them. Regardless of which process had the bug—the compiler or compiled program—in both cases the processor only did the bad thing because the compiler told it to. And in both cases the fix is the same: the compiler's code must change, since that code was what caused the memory corruption.

Just like every compiler, Roc's has had bugs, and some of those have been miscompilations that led to memory corruption. That said, while 8 of the 10 memory corruption bugs in the Zig-based compiler were also miscompilations, the remaining 2 were in the compiler itself. Both were use-after-free bugs in error reporting, with the same symptom: filenames in error messages (one in roc check and the other in roc bundle) rendered as useless question-mark-in-diamond characters. Rust's borrow checker would have caught both.

Now let's suppose we had instead chosen Rust for our rewrite, or Zig with ReleaseSafe. What would have been the impact in practice, holding all else equal?

Tooling Choice Memory-safety impact in practice ZigReleaseFast 2 bug reports: some errors fail to render filenames Zig ReleaseSafe 2 bug reports: some errors panic and don't render Rust's borrow checker neither of these bug reports

After 18 months of development, hundreds of total bug reports, and hundreds of thousands of lines of code, my main takeaway from retrospecting on this table is that picking a different row would have made no appreciable difference to the project. So far our choice has gotten us the outcome we'd hoped for.

As I noted earlier, every project has different needs. When Bun rewrote in the opposite direction—from Zig to Rust—their accompanying post noted:

For Bun, correctly handling the lifetimes of garbage-collected values [from JavaScript] and manually-managed values has been a major source of stability issues - most often small memory leaks and occasionally, crashes. Every memory allocation has to be meticulously reviewed. Where do these bytes get freed? How do we ensure it only gets freed once? Did we check for JavaScript exceptions properly? Is this garbage-collected pointer visible to the conservative stack scanner? Is this garbage collected memory or manually managed memory?

Roc's compiler doesn't have these particular challenges because it doesn't interface with JavaScript or any other tracing garbage collector. For Bun, "use-after-free, double-free, and 'forgot to free'" errors have been "a large percentage of bugs," whereas errors like these have been a small percentage of Roc's bugs. And of course Roc's compiler faces other challenges that Bun doesn't. Different projects have different needs!

In our case, I'm not sure how I could look back at what's actually happened and conclude that what we needed was a bigger investment in tooling to prevent memory safety bugs in the compiler itself. There's a much stronger case that we would benefit from better tooling to catch memory safety bugs in our compiled output, which has always been out of scope for the borrow checker.

Build Times

We wanted faster builds from Zig. Did we get them?

Well, the good news is that zig build --watch -fincremental can rebuild a change to our current ~450K lines of Zig code in about 35 milliseconds. That's even faster than what we were hoping for when we considered Zig's build speed a selling point for the rewrite!

The bad news is that Zig's current stable 0.16.0 release has a bug that breaks -fincremental on our code base. The fix already landed, but to get it we'd have to build on a nightly 0.17.0 prerelease build (which has breaking language changes), along with vendoring and upgrading our affected dependencies to 0.17.0. We decided to wait for the next stable release instead.

As of the last commit that had Rust sources in our code base, here's a timing comparison on my Intel desktop machine running Ubuntu 26 for building cold (no cache, but packages downloaded locally) compared to doing an incremental rebuild after making a trivial edit to our parser:

Roc Compiler Version LoC Cold Build Incremental Original on Rust 1.85.0 354K 32.4s 10.0s Original on Rust 1.97.0 354K 25.4s 3.4s Rewrite at feature parity on Zig 0.16.0 320K 39.6s 8.6s Rewrite today on Zig 0.17.0 464K 32.1s 0.035s

Note that our Zig build configuration as of the feature-parity commit was rebuilding rarely-changing artifacts on every build that we later decided to rebuild only on demand. That's why today's cold builds are faster than they were back at 300K LoC, even though our lines of code have increased by ~50% since then.

Rust 1.97 is the current stable release today, and 1.85 was the current stable release 487 days ago (the time our rewrite took to reach to feature parity). So if we'd stayed on Rust for the same duration, we could have seen our incremental build times decrease from 10 seconds to 3.4. That's a big jump! I really appreciate all the hard work that Rust contributors have done to improve build times. Eliminating 2/3 of our incremental build times over 18 months would have been a very welcome change if we'd stayed on Rust, and it's a bigger improvement than I would have anticipated in an 18-month period. Bravo!

As impressive as that improvement is, Zig's 35ms is still way ahead. Not only is it 1/100th the build time of 3.4 seconds, it's also in a different performance category—and that 35ms is on a Zig code base with ~50% more lines of code than the Rust one that got 3.4s. I expect Roc's code base to keep growing, and for this gap to keep growing with it; I've never heard of any initiative on Rust's roadmap comparable to -fincremental.

So while our decision to remain on stable 0.16.0 (plus how many of our contributors run Mac laptops with ARM processors; -fincremental only works on x86-64 CPUs right now) means we haven't yet reaped the anticipated build-time rewards of choosing Zig for the rewrite, we certainly have something to look forward to in the next stable Zig release!

Memory Control: Zero-Parse Deserialization

Roc's new on-disk caching system uses a technique I first learned about from Zig's compiler, and which Casey Muratori told me is common practice in game programming. It relies on the happy coincidence that if you're organizing your memory in the way that runs fastest on modern hardware anyway, you can also load it from disk directly into memory and start using it without parsing anything.

Here's how it works:

  • All of our compiler data structures are represented as arrays with 32-bit indices over pointers (and often in structure-of-arrays form).
  • This not only saves memory and runs faster, it also means our data structures can be written directly to disk without needing to be serialized into a different format first.
  • The bigger benefit is that this lets us deserialize them back into memory without parsing the on-disk bytes in any way. We load the bytes into memory, do some relocations to point our existing data structures to the newly-loaded arrays, and we're ready to go.
  • This means we deserialize at the speed of loading the bytes from disk into memory—so, actually I/O bound. If those bytes are already in the operating system's disk cache, it means we load cached work from previous builds at roughly the speed of memcpy.

When you run roc check twice in a row, the first time it caches all of its outputs on disk using this strategy. The second time, if the input source code files haven't changed, all the parsed/type-checked/etc. data structures jump straight from disk into memory. It's extremely fast. roc test similarly caches the outcomes for tests of pure functions (which are deterministic), and all of this is done with file-level granularity, so if you change one file you'll only be paying for redoing work of that file and any others that depend on it.

This zero-parse deserialization strategy only works because we're following this programming without pointers style for all of our compiler data structures. If we instead used pointers everywhere (like almost all compilers do), deserialization couldn't be zero-parse.

This approach has safety risks, however. Similarly to how a pointer in memory can point to the wrong address (e.g. leading to a use-after-free), any index can be used as a lookup into the wrong array at runtime, at which point you end up with whatever random bytes happened to be at that location. Rust's borrow checker is designed to help with pointer lifetimes, but it doesn't attempt to answer the question "which index goes with which array?" because that has never been in scope for its design.

If you know exactly how many of these arrays you need up front, the Rust crate compact_arena can help you avoid indexing into the wrong array by generating type tags with a macro. Unfortunately, if you can't know exactly how many you need up front (e.g. because it varies by number of modules, as it does in our use case), this technique doesn't work. That's why compact_arena marks SmallArena::new as unsafe.

Personally I wouldn't label SmallArena::new as unsafe. unsafe is supposed to mark the parts of your code base that should be audited extra-carefully, and creating an empty arena doesn't need auditing because it can't cause unsafety. Unfortunately, the potentially-unsafe operation is indexing into an array, which comes up constantly. "Audit every part of your code base extra carefully" is not great advice, and neither is "avoid this technique that massively improves performance" when Zig itself has shown that a spotless memory-safety CVE record is achievable while doing exactly this.

Safe Rust is effective in practice because it assumes that the amount of Unsafe Rust in your code base is small and isolated, and that assumption holds for the vast majority of Rust code bases. But if unsafe is going to be pervasive, like in our case, the assuption no longer holds, and it starts to sound more appealing to choose a language that's safer than Unsafe Rust.

Ecosystem Relevance

The Bun post talks about how Rust's Drop could help with their unusual JavaScript inetrop challenges:

[...] other users of Zig don't have the bugs we had, and mixing GC with manually-managed memory is an uncommon enough thing for software to need that no language really designs for it. [...] One common way to reduce this class of issue is to ensure cleanup code is always run exactly once for code that needs it. Zig is designed to be a simple language with no hidden control flow, and so it prefers the explicit defer keyword to run code at the end of a scope over C++'s implicit ~Destructor or Rust's implicit Drop.

We're in the opposite situation: Drop has been a pain point for us because the Rust ecosystem is built around the assumption that everyone is using a global allocator and using Drop for implicit deallocation. But we want to be doing almost the reverse: separate arenas for each module and stage of compilation. Zig's ecosystem consistently passes around allocators, which is exactly what we want, whereas off-the-shelf Rust crates almost always assume a single global allocator.

Simply put, Rust's ecosystem is optimized for the way Bun wants to be written, whereas Zig's is designed for the way Roc wants to be written.

Separately, there's the question of what relevant code we can access off the shelf. LLVM is a critical dependency for our optimizer (we do our own optimizations, but LLVM does more on top), but it's also a project that makes major breaking API changes on a regular basis. Upgrading to new LLVM versions has been a major source of pain and lost time for Roc, but we keep doing it because we want the new optimizations.

As it turns out, LLVM actually has a stable and backwards-compatible API that can be accessed to bypass this upgrade pain: its serialized "bitcode" format. If you write your own LLVM bitcode serializer, then you can tell each new version of LLVM to consume that, and you're off to the races.

Of course, to access this strategy, you need a handwritten LLVM bitcode serializer that's decoupled from the LLVM C++ library and its breaking changes. I only know of one implementation of such a thing in the wild: Zig's compiler, which of course is written in Zig. And now there are two implementations in the wild, because Roc's new compiler is reusing that same Zig code. (Thanks for sharing it, Zig team!)

You might have noticed that the biggest source of dependencies we're interested in from the Zig ecosystem is the Zig compiler itself. This is unusual, but Roc is an unusual project with unusual needs. When I wrote the first line of code in the compiler back in 2019, I would not have guessed that the following would prove true: "In the future, the richest gold mine of reusable code for this project will be an open-source compiler written in a language you haven't heard of yet."

Life is full of surprises!

Things I Miss From Rust

Even though I'm no longer using Rust for Roc, I remain immersed in the Rust world because I work at Zed, where we use it for pretty much everything. So when I say I miss something from Rust when building with Zig (or vice versa), it's not just rose-tinted memories of a distant past; it's more like memories from earlier in the same day.

Something I was surprised to find myself missing from Rust is automatic allocation and deallocation in tests.

As discussed earlier, having full control over allocations and deallocations is what I want in our compiler's implementation. And in tests, I also appreciate the testing allocators detecting leaks—it can even detect leaks in compiled Roc code! Unfortunately, to get that benefit requires a lot of "init this, defer deinit" code in tests that has to be correct or else the test fails on a memory leak. None of that is necessary in Rust. I care more about the compiler's implementation being the way I want it than the tests looking nicer, but in a perfect world I could somehow have both.

Both parametric polymorphism and ad hoc polymorphism overlap with comptime, so it makes sense that Zig doesn't have them, but I do miss them. For example, Rust's Allocator trait has its allocate function taking "self" at its first argument, whereas in Zig, allocator implementations like ArenaAllocator need to receive an anyopaque pointer and then cast it to itself.

I also miss private struct fields. I understand the reasoning for not having them, but I miss getting a compile error if I use something that is marked as "not supposed to be accessed directly like this, even though it can be done if you really want to." This comes up when reviewing a diff, because in the diff I just see the field access; I don't see the docs on the original struct definition, and I don't want to go out of my way to look them up defensively every time.

Occasionally I miss functions and variables and constants all using snake_case.

I do miss aspects of unsafe and the borrow checker, even though their upsides come packaged with downside I don't miss. I don't think Zig should add either of these, but at the same time there is something calming about only worrying about certain classes of problems inside unsafe blocks. I can miss that feeling even while not wanting to pay the corresponding costs in this project.

I'm not sure how much of this is because of the way comptime works, but I certainly find myself being surprised to discover dead code in our Zig code base (which was caught by neither Zig's built-in tooling nor TigerBeetle's tidy.zig—by the way, thanks for open-sourcing that, TigerBeetle team!) more often than I'm used to from Rust. Dead Zig code doesn't affect end users because the compiler doesn't even emit it into the binary, but obviously it would be better for our code base if we discovered it earlier.

Finally, the Rust team does an admirable job with backwards compatibility in their releases. Upgrading to new minor releases barely took any effort, and even edition upgrades were mostly painless. Backwards-compatibility is a non-goal for Zig in its current stage of development, which is something we knew about going in and expected. It hasn't been a big problem for us, but do I miss the trivial upgrade process we had in Rust? Of course!

Things I Enjoy About Zig

I've always enjoyed the subtractive aspect of functional programming. You'd think that subtracting tools from my toolbox that I'm accustomed to reaching for (e.g. mutation, unrestricted side effects, objects and classes) would be frustrating…but once I got used to the different techniques, I really came to enjoy the new properties I had unlocked (cacheability, non-flaking tests, concurrency niceties, reordering operations with no fear that their outputs might change, etc.) and no longer wanted to give those up.

I have similar feelings about Zig. I like that it doesn't have macros. I may miss ad hoc polymorphism, but at the same time I enjoy how many problems (including parametric polymorphism) can be addressed by comptime and/or an ordinary function.

I love the control over data layouts. It's great having out-the-box access to number types that aren't a power of 2, like u7 and u5, without having to do any bit-level work myself. Packed structs out-the-box, the option to inline functions at the call site instead of the declaration site…these are things you can get from Rust crates using macros, but I really like having them available without needing a separate dependency.

Zig's build toolchain is second to none, which is presumably why Uber uses it even though they don't use Zig the language. Building self-contained binaries for things like Alpine Linux and WebAssembly has gone really well, even though we're doing weird stuff like compiling part of our code base (the "builtins"—Roc's standard library, essentially) into an opaque binary blob and including it in the final executable.

I also really like Zig's error-handling strategy, and especially how failed heap allocations are normal userspace errors. Roc has a similar "errors naturally accumulate" strategy (except using anonymous sum types that can have payloads), and I like both of those strategies better than anyerror, thiserror, or vanilla no-dependency error handling in Rust with Result. (That said, I do prefer Rust's postfix unary ? operator over Zig's try keyword, which is why we adopted the postfix unary ? operator in Roc.)

Then of course there's all the project-specific stuff which I mentioned earlier: allocator-based APIs everywhere, an ecosystem of high-performance compiler goodies that we can't find anywhere else, and so on. I won't rehash them all here, but I very much enjoy them in addition to appreciating the benefits they've had to the project.

I've had a very positive experience with Zig all around, and looking back I'm really happy that we chose it for our rewrite!

What's Next for Roc

We aim to land version 0.1.0 of the new compiler later this year, which will be Roc's first-ever numbered release. You're welcome to try out a Nightly build before then, although in its current state you can still expect a variety of bugs, incomplete features, and unfinished docs. I have a lot of documentation to write between now and then!

By the way, the Roc Programming Language Foundation is a 501(c)(3) nonprofit, so if you'd like to make a donation it will be tax-deductible in the US, and we use donations primarily to compensate contributors. If you know of an organization that would like to sponsor our work, financially or in other ways, please get in touch! (Separately, if you know anyone at GitHub who could get us into GH for Nonprofits, that would be a huge help with our CI backlog.)

Thank you again to everyone who has helped the language reach this milestone. I couldn't be more excited for the next one: our first-ever numbered release! If you'd like to follow along, ask questions, or just come say hi, feel free to come chat with us on Roc Zulip.

The Daily Front Page 4 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — The Lost Record Room
article

The lost joy of music piracy

by mcgin·▲ 819 points·582 comments·pigeonsandplanes.com ↗
HOME TAPING IS KILLING MUSIC

What.CD, Oink, and the banalities of streaming.

The Lost Joy of Music Piracy

Design by Tyler Farmer.

“I was responsible for the dancing baby meme,” Rob Sheridan laughs over a video call. Now better known as an accomplished graphic designer and the former creative director of Nine Inch Nails, Sheridan’s list of accolades date back to 1997, when he created a website dedicated to the low-resolution animated GIF.

The first thing I notice when our interview begins is his t-shirt, which bears the ironic slogan “HOME TAPING IS KILLING MUSIC,” a phrase once printed on record sleeves in the UK during the ‘80s alongside a cassette tape resembling a skull and crossbones, later used in the logo of the infamous BitTorrent tracker The Pirate Bay. During his decades-long career in the music industry, Sheridan has stood out as one of the few outspoken advocates of media piracy.

“I was really early on the internet, making websites just as a hobby in high school to teach myself HTML,” he explains. “I remember the first time I was able to download the leak of a new song, Nine Inch Nails’ 1997 single ‘The Perfect Drug.’ It was played on a radio station and someone recorded it, and was able to upload it in this RealAudio format which compressed music in a way that made it manageable to download for the first time.”

While attending New York’s Pratt Institute the following year, Sheridan dove headfirst into the world of illegal file sharing. “Very much in the way that Napster originated, people had their own servers with files in public folders. You’d connect to the local network at your dorms and people had their mp3 collections on there. I ended up discovering a whole bunch of music that way. I downloaded a whole bunch of albums that I never had the opportunity to try because every album was an 18 dollar investment. That kind of radicalized me, and I became a fan of so much more music.”

While barely finished with his first year of art classes, a Nine Inch Nails fan site Sheridan had created caught the attention of the band, and in 1999 he was hired to design their official webpage.

“I ended up leaving school and moving down to New Orleans, living down at their studio and working there. One thing led to another and I became a creative partner, then art director, and doing more stuff,” he explains. “They'd all been kind of holed up in this studio for a couple years following up their huge album [The Downward Spiral], and it was all very secretive. I was the kid coming in with a bunch of energy and all this technological interest—like hey, check out this LimeWire thing. Everything we did with Nine Inch Nails was very confrontational with new technology.”

As an avid pirate suddenly finding himself in the midst of the music business, Sheridan saw the issue from a different angle than most of the suits he was surrounded by. “I got brought in and we were being flown to New York, the label was taking us out to these expensive dinners and paying for everything—top notch hotels, everyone had private cars and drivers. There was so much money going around, and it wasn't the artists who were rolling in cash. I remember one of my first comments to Trent [Reznor] was, ‘Now I see why CDs cost 18 dollars.’”

Rob Sheridan.

During our interview, Sheridan admits that he eventually invited Reznor to Oink’s Pink Palace, a private BitTorrent tracker for music which the frontman later called “the world’s greatest record store” during an interview with Vulture.

Launched in 2004, Oink’s Pink Palace was created by a 21-year-old computer science student in England in response to legal action being taken against users of public file sharing services like Napster and The Pirate Bay. In a few short years, Oink grew into a massive community of like-minded music enthusiasts, offering high-quality downloads of virtually every album in existence. “It was like opening a secret door to this incredible world,” Sheridan reflects. “File sharing was everywhere, but you'd never seen this level of care and detail. You see those videos now where they bring film people into the Criterion Collection—it felt like that—being invited to the ultimate music collector’s den, the private club where they show you all these records you never thought you'd have. Most people didn't have the same kind of experience, they got the LimeWire version which was the equivalent of wading through a dollar store that’s just been ransacked and shit’s all over the floor.”

By the time Nine Inch Nails’ comeback album With Teeth hit stores in 2005, it had already been available to download on Oink for a number of weeks. Rather than joining the growing number of artists angry at their fans for not waiting to pay, Sheridan says they saw it as a failure on the side of the music industry. “We knew that the moment we sent it to the record label it was gonna leak. At the end of the day, Trent is a music fan too—if someone is like, ‘Hey, you can listen to the new album from your favorite band right now, or you can be good and wait three more weeks,’ of course you’re not going to wait. It's not really a moral question at that point. Instead of blaming them, we looked at the problem—which was the record label. That’s when we decided what we’re going to do is release the digital version first through our site, and then send it to the label. The CD can come out later.”

For the rollout of their next album, Sheridan and Reznor leaked singles via USB drives hidden inside the venues of their 2007 tour, kicking off a masterclass in viral marketing: an alternate reality game immersing fans in the dystopian future of Year Zero. Clues encoded in the mp3 files and tour merch directed participants to a string of websites and phone lines revealing further details of the album’s concept, music videos, cover art, and eventually the album in its entirety.

In October of 2007, Oink’s servers were raided by police and the website’s creator was arrested. The following day, Sheridan penned a eulogy for the website in a blog post titled The Death of Oink, the Birth of Dissent, and a Brief History of Record Industry Suicide, where he called the website “the most complete and most efficient music distribution model the world has ever known,” and added that he would gladly pay a large monthly fee for any legal music service of the same level.

The next year, Nine Inch Nails released their album The Slip for free via BitTorrent and direct download from their website, where Reznor wrote, “Thank you for your continued and loyal support over the years—this one's on me.” Though they weren’t the first major band to release an album for free during this era (Radiohead released In Rainbows via the pay-what-you-can model the previous year), this method marked the beginning of a change that would take the rest of the industry years to catch up to.

“It was us acknowledging how much of a download world it was, but it was also just continuing to experiment,” Sheridan says of the decision. “Part of that was acknowledging very early on that collecting your audience is very valuable. We got the email addresses of everyone that got it for free so that we could reach out to them about the tour that we were about to do, and sell tickets to that. Everyone was trying to figure out what their place was in terms of how to have an economy around music at that time. They had made music so expensive to access, while at the same time you've got Apple being like, ‘You can store a million songs in your pocket!’—okay well, I don't have a million dollars.”

Following Oink’s closure, record labels pressed on with business as usual, and a new private tracker called What.CD quickly sprung up to fill the void it left behind. In a short matter of time, the community would grow to match the vast library of its predecessor—a success which Sheridan sees as a product of the industry’s failure to adapt in an increasingly digital landscape. “I got on What.CD and kept rolling on there. It really was an answer to something that the music industry wasn't providing at the time. Now that’s kind of acknowledged by streaming music. It has a lot of major flaws, but kids today have no concept of how amazing it is. It should cost a lot more to be able to just flow through the entire history of music. You had to have been there in that private club to realize how special it is to have that access to music for everybody now.”

Much like Oink, What.CD carried a strong emphasis on quality, and enforced a strict set of rules regarding cataloguing, seeding, audio quality, and file-naming conventions. Gaining access to the website was only possible through an invitation from another member, or by successfully completing a lengthy text interview through their internet relay chat network (IRC), a process which required a strong understanding of audio formats, ripping, torrenting, and transcoding.

Amongst communities of music nerds online, membership on What.CD was regarded as the holy grail of internet piracy. After discovering 4chan’s music board, I became aware of the website through frequent posts where users either bragged about their access to the tracker or openly begged for invites. Others insisted that the private tracker secret club was overhyped and unnecessary—a sentiment often met with a wave of responses referencing the fable of The Fox and the Grapes.

For a young music nerd such as myself looking to expand their iPod beyond a meager collection of CDs and costly iTunes downloads, What.CD was exactly what I was looking for. Eventually, I learned that someone in my Counter Strike gaming clan was a member, and was happy to send me an invite.

Finally able to navigate past the What.CD’s homepage—a mysterious login screen that read, “Beyond here is something like a utopia”—I familiarized myself with their textbook-length list of rules, and spent countless hours combing through everything the website had to offer. Virtually every album, re-release and re-pressing from any artist's discography was available in a list of high quality audio formats ranging from lossless FLAC to MP3 V2, ripped from your choice of CD, vinyl, or digital download. No longer did I have to scour music blogs and forums for active Mediafire links, or search half a dozen torrent sites for obscure albums I had read about online—the entire world of music was suddenly all in one place.

As exciting as this meticulously organized archive of free music was, I wouldn’t be able to invite my friends to let them join in right away. In order to acquire closely-guarded and highly sought-after invites, I would need to climb the rung of user classes to achieve the status of “Power User” by uploading a number of torrents myself. While at first it came off as unnecessarily elitist and gatekept in nature, the rules in place within the shadowy cabal of private trackers existed for a reason.

“If [trackers] are public and they're easy to get onto, that means they're also easy for law enforcement to get onto, so all they have to do is download the torrents and see who they're connecting to. Having a private site with some pretty hefty walls around it means that you are much more secure,” explains a former staff member and moderator of What.CD, who requested to go by the pseudonym ‘Brian’ during our interview.

“The other reason is that there’s no community on public sites,” he continues. “There are no accounts, there's no motivation really other than just your own goodwill, so it’s very common to download something and stop seeding it on your computer. Private trackers are built around having one account and one account only. They're built around tracking your ratio of upload to download. Those two things together provide a lot of incentive to continue seeding your content and making it available to other people. That’s what created the magic of private trackers being comprehensive and reliable.”

After passing the interview and becoming a member in 2010, Brian says his first impression of the website was something of awe. “Very quickly I understood what this site was—how much respect people had for it, and how much work it took to build and maintain. The community was very active on the forums and IRC,” he adds. “Still to this day, I've never seen such an actively maintained network of knowledge and output, it really sucked me in. Every band or album had a word cloud that showed connected artists, and I found a lot of music just by clicking around and optimistically downloading things. There were also the collages that people would make for their own personal taste, or by themes which ranged from, ‘Here are all the Pitchfork 10s,’ to, ‘Every album that has a train on the cover.’”

The following year, Brian became a staff member himself. “These places felt like some of the last survivors of the bulletin board style forums in a world that is now just Reddit and Instagram comments. There’s a real magic to that kind of communication. It was a lot of what I wanted in an internet community. I got involved on the interview team at the start. From there, they eventually asked if I wanted to handle more sensitive responsibilities and join the mod team.”

As a member of the mod team—the shadowy cabal within the shadowy cabal—Brian’s responsibilities mainly consisted of enforcing the rules surrounding user accounts, a task he says he handled when not occupied with high school homework assignments.

By this time, around 2011, What.CD had become the largest archive of music in human history. Taking the hard-earned lessons from their predecessor, the tracker had stayed under the radar of four-letter agencies. While operational security and potential legal action was always top of mind for staff, Brian says that during his tenure on the team, their largest perceived threat was a brief scare from the J.D. Salinger estate.

On What.CD, one of their most popular functions was the request system, a member-led bounty economy driven by the incentive of gaining valuable upload ratio. If a user wanted something that wasn’t already available on the website, they could create a “request” for it, offering a portion of their own upload credits to whoever was able to fulfill it. Typically, these requests could be filled by spending around $20 on Amazon or iTunes and uploading the files. For highly-sought after releases yet to hit shelves, other users chipped in on the bounty, creating a massive reward for whichever record store employee was willing to “borrow” a copy from the backroom before the release date, resulting in the website frequently becoming the originating source of album leaks online. The largest request on the website was for “The Ocean Full of Bowling Balls,” an unpublished short story by Salinger only known to exist in a locked room of the Princeton Library, where it was available by appointment to read under the strict supervision of staff.

“It was basically like a long running joke that it would never get filled,” Brian laughs. “And then somebody fucking did it.” In November of 2013, a What.CD user tracked down one of the 25 copies of the manuscript allegedly printed in 1999 in order to fill the request. The unexpected leak made headlines around the world, and the torrent file was quickly pulled. “It was known that the Salinger estate was very legally motivated, very litigious,” he says. “It would never be allowed to actually be on the website, so the hand was forced.”

While the unwanted attention raised alarms amongst staff, Brian says nothing came of it. “To the best of my knowledge, [action from law enforcement] never happened until the very end, and even that was pretty minor, but we were constantly paranoid about it. It was a huge thing on the back of our mind at all times.”

In November of 2016, users attempting to log into What.CD were shocked by a message displayed on the homepage, stating that, Due to some recent events, What.CD is shutting down. We are not likely to return any time soon in our current form. All site and user data has been destroyed. So long, and thanks for all the fish.

According to a French cybercrime website, authorities had seized a number of What.CD’s servers earlier that day. The sudden closure came as a surprise to their 165,000+ registered users, including staff like Brian. The site never returned, and no further details were ever made public to the mourning userbase. Even today, speaking about the loss of What.CD with an Anonymous stranger feels like recounting the death of a close friend.

“I feel like it’s probably okay to share the top level detail about this,” Brian says somberly. “What.CD’s security was very strong. It was run by very smart people who knew their stuff about being systems operators. Ultimately, some servers in the network were taken down by a French enforcement arm. They were just reverse proxies, which didn’t store anything actually sensitive, except maybe a connection that led to the real servers. That was the first and only time—that I'm aware of—that any kind of enforcement had been launched against the site. Even though nothing happened, even though there is a world where we could have just replaced the server and switched all the hosting in that country to something else and continued onward, the admins made the very reasonable decision to say, We are now in a different sector of risk, we’ve gone from zero actions taken to one action taken, we’re abandoning ship—and started deleting everything. There was no way of knowing whether they would follow the trail to the next server. It was obviously a very difficult decision and the admin I talked to after was really upset about it, but I'm sure many people agree it was also the sensible choice to make.”

Coinciding with the shutdown, streaming services had begun to reach the masses. As CD sales continued to plummet, labels moved away from physical releases, and major leaks became increasingly infrequent. Suddenly, anyone with a $10 monthly subscription had access to the entire world of music without even having to download it to their hard drive. In the wake of What.CD’s closure, I begrudgingly became a paying Spotify user. The record labels had finally won the war against music piracy.

Looking at the state of things now, much of what Sheridan predicted in his 2007 blog post has come true. Music has more or less become free, and we finally can pay a nominal fee for a service once offered only by illegal websites like Oink. Reflecting on his thoughts from nearly two decades ago, Sheridan isn’t so certain that we’ve arrived in a promised land.

“The idea at the time that music will eventually be free was more of a statement of where this is heading. Streaming is not economically sustainable for artists. The solution to all of this should have also been something that renegotiated how artists are paid for music. Instead, you've got artists who can’t even afford their bills even though they’re getting millions of streams a month, and you have Spotify paying Joe Rogan 100 million dollars. Artists are the ones being mistreated, while the middlemen are making a ton of money. There’s this whole world where people exist just to show up in the middle and take a big chunk of the money from something they had no part in creating. The Spotify model is like saying your favorite restaurant should just be giving you free food until you decide to buy a t-shirt. If artists weren’t also being screwed on the touring side, then you could think of streaming as marketing, but there’s too many examples being dangled in front of our face of how much money is being funneled away to billionaires and corporations, and artists always come last. That’s my frustration with where we’ve ended.”

At its core, What.CD was much more than a torrent tracker—it was a community of dedicated music fans volunteering their time, effort, and knowledge to create a labor of love. While the internet has opened up new doors for musicians, the increasing corporatization and algorithmic takeover of online spaces has done irreparable damage to organic music discovery and independent communities. Rather than bringing about the financial stability that was supposedly impeded by piracy, the recording industry has offered meager artist payouts and a product with a sterilized user interface. Nearly ten years since their unexpected goodbye, the magical feeling of logging in to What.CD has yet to be replicated by any streaming service. Though my days of stealing music are well in the past, it’s clear that musicians are still being stolen from.

The Daily Front Page 5 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Purchased, Then Vanished
article

Sony deletes more movies from the accounts of people who ‘bought’ them

by nekusar·▲ 687 points·435 comments·techdirt.com ↗
People don’t actually own the things they think they’re buying.

In all of our discussions about how the digital revolution has created a system in which people don’t actually own the things they think they’re buying, I get particularly frustrated by the lack of change in it all. We’ve spilled much ink complaining that this clearly anti-consumer practice needs to be done away with, where an unsuspecting public thinks they’re buying “a thing” only to learn months or years later that “the thing” they bought was actually a license to use/view/listen to another “thing”, and that license exists at the pleasure of the company that collected the money for it. And if you want to see the lack of change or action really honed in upon, let’s take a look at Sony’s PlayStation Store.

In 2022, due to “evolving licensing agreements” with distributor StudioCanal, German and Austrian users had hundreds of movies disappear from their PS accounts, long after buying them through Sony. Then in 2023, it happened again in America, specifically when Sony ended its licensing agreement with Discovery after the Warner Bros. merger, which, of course, has since been bought by Paramount Skydance. That resulted in customers having hundreds and hundreds of episodes of TV shows deleted from their accounts. Nowhere in any of this were there refunds, of course. No recompense at all, actually. Just a thing you thought you’d bought taken away from you by the very people you thought you bought it from.

And now it’s happening again. Due to another licensing agreement fallout with StudioCanal, hundreds of movies and TV shows are being ripped from the accounts of PS Store customers, and there appears to be fuck all that they can do about it.

This news was brought to people’s attention by X user somatyk, who posted the notification they had received from PlayStation this week. Along with the unapologetic news that the purchased movies would be deleted from their account on September 1, the message concluded with, “Click here for a full list of affected titles that will no longer be supported. Thank you.” The same warning is now reproduced in full on the PlayStation website, along with the list of 551 films and TV series that are being pulled from people’s libraries.

As Kotaku notes later in their post, part of what is striking in all of this is the sheer mundanity of the announcement. Because there have been no consequences, or any action at all from the public or government, Sony treats this all as if it’s perfectly normal and no big deal. You can tell me all you want about how the Ts and Cs in these purchases do in fact note that the nature of the purchase is a temporary licensing of the content for an undetermined time period… but I can promise you that the public in general doesn’t understand that. They think they’re buying a thing, not a license.

And that’s because of the purposeful obfuscation of that fact. Sony damned well knows that the vast majority of people don’t read those Ts and Cs. It knows that the public largely doesn’t understand how these backend licensing agreements with distributors work, or that they even exist. And Sony isn’t exactly putting out a big blinking sign on its store pages informing the public of all of this. Instead, the company is only too happy to collect money from a public that is being purposefully kept ignorant of what they’re buying.

Of course, when you scroll past the endless EULAs when you first use your PlayStation, and click “Agree” the first time you load the store, you’re unwittingly agreeing that nothing you buy is really truly bought, and that it can be taken away from you at any point, and there’s nothing you can do. The same is true of your games.

This, too, will probably pass without any real action. The government has done its best to gut our consumer protection agencies, so they won’t be any help. Angry customers won’t coalesce into activism or action, most likely. And I’ll probably be writing another one of these posts in a couple of years when it all happens again.

But it shouldn’t be that way. There are common sense things that can be done to better inform the public. Rules for how the store should inform people with each and every purchase. Someone just needs to demand it be done.

The Daily Front Page 6 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Dispatches from IRC
article

Microsoft Comic Chat is now open source

by jervant·▲ 790 points·173 comments·opensource.microsoft.com ↗
The chat client that brought Comic Sans to the world is now on GitHub.

Comic Chat Open Source

Microsoft Comic Chat is now open source

The chat client that brought Comic Sans to the world is now on GitHub

Today, we’re excited to announce the open-source release of Microsoft Comic Chat, the chat client that automatically turned conversations within Internet Relay Chat (IRC) into comic panels featuring illustrated characters, speech bubbles, and expressions, and helped introduce the world to a little font called Comic Sans.

Yes, that Comic Sans. Originally designed by Microsoft typographer Vincent Connare in 1994, Comic Sans found its first real home in Comic Chat, where its informal, hand-lettered feel matched the software’s speech-bubble conversations perfectly.

Explore Microsoft Comic Chat on GitHub

For many people, Comic Chat is a nostalgic artifact from the early days of the internet as we transitioned from technologies like telnet, Usenet, and IRC to the largely visual web that we enjoy today. For others, it’s a legendary piece of Microsoft history they have only heard about in stories, screenshots, and debates about typography. Now, developers, historians, retro computing enthusiasts, and anyone who appreciates a wonderfully unconventional idea can explore the source code for themselves.

A different vision for online communication

Today we’re accustomed to messaging apps with reactions, stickers, GIFs, avatars, video, and AI-generated content. But in the mid-1990s, internet chat was largely walls of scrolling text.

Rather than displaying messages as plain text, Comic Chat presented participants as illustrated characters. Conversations unfolded in comic panels, with speech bubbles, expressions, and gestures generated from what people typed. If someone wrote “I like that,” the character might point to itself. If the text suggested anger, the character might frown or cross its arms. It was quirky, ambitious, occasionally chaotic, and surprisingly forward-looking.

Many ideas we now take for granted in online communication can trace some of their spirit to experiments like Comic Chat.

The people who built it

David “DJ” Kurlander, working in the Microsoft Research Virtual Worlds Group, conceived the idea of a new visual representation of conversational histories, and started developing Comic Chat in 1995. Built in Visual C++ 4.0 and MFC, Comic Chat was released in 1996 with the Internet Explorer 3 web browser.

Under the hood, Comic Chat was more than a clever skin for IRC. It was able to interpret conversational cues in the text and choose appropriate poses, facial expressions, gestures, and panel layouts. That meant Comic Chat was not simply displaying messages but also making real-time editorial decisions about how a conversation should look and feel as a comic. DJ, Tim Skelly, and David Salesin published a paper on the technology in Comic Chat at SIGGRAPH ’96, a computer graphics conference, describing what they had built as an experiment in automatic illustration construction and layout.

The visual world of Comic Chat was the work of Jim Woodring, a highly regarded independent comic artist whose characters gave the software its distinctive look. The team would hand Jim transcripts of real chat sessions to illustrate, then use the results to figure out whether the whole idea was worth pursuing. It was.

Why open source it now?

Comic Chat represents a fascinating chapter in the evolution of online communication. It emerged during a period when the internet was still discovering what it wanted to become. Many rules had not yet been written, which gave developers permission to try bold concepts that might seem unusual even today.

By releasing Comic Chat as open source, we’re preserving an important piece of software history and giving the community an opportunity to explore, learn, and build upon it.

The source is available now for exploration, study, and experimentation. Alongside the original snapshots, we’ve included a few AI-powered modernization attempts that demonstrate what’s possible—getting this 1990s-era C++ and MFC code building with current Visual Studio tools, connecting to modern IRC servers, and running legibly on today’s high-resolution Windows machines. These are not polished re-releases, but worked examples that show Comic Chat can still come alive on modern systems. We’re excited to see what improvements, ports, experiments, and entirely new forms the community brings to it next.

A time capsule of internet optimism

Looking back, Comic Chat captures something special about the era in which it was created.

The early web was filled with experimentation. “What if chat rooms looked like comics?” That question sounds wonderfully unreasonable. And yet it was built, shipped, localized into 24 languages, and bundled with Windows 98.

That’s part of what makes Comic Chat memorable decades later. It reminds us that innovation often starts with ideas that are playful, unconventional, and creative.

One last speech bubble

Comic Chat was created during a period when software teams were willing to color outside the lines, literally and figuratively. DJ Kurlander, Tim Skelly, David Salesin, Jim Woodring, and everyone else who touched this project made something that people still remember and still run thirty years later.

Take a look at the source code, explore what they built, and use its story as inspiration to come up with new unconventionally delightful things to create.

And if you happen to read the source code in Comic Sans, we promise not to judge.

Build your own chapter of Comic Chat history

The source is open, the characters are waiting, and the speech bubbles are yours to fill. Whether you want to study a piece of 1990s Microsoft history, modernize the code for today’s systems, or reimagine what visual chat could look like in 2026, it all starts in one place.

Get the code on GitHub

The Daily Front Page 7 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Typography vs. the Machines
article

Decoy Font

by ray__·▲ 697 points·156 comments·mixfont.com ↗
Up close it reads as the decoy text; from a distance it reads as your hidden message.

A TTF font that hides what you're typing from AI. Type a message where each letter contains a decoy.

Decoy Font hybrid image preview. Up close it reads as the decoy text; from a distance it reads as your hidden message.

Download Decoy Font

Decoy Font is free to use in personal, commercial, and client projects. Its letterforms are derived from DejaVu Sans Mono; see the full font license for terms.

Download TTF

What is Decoy Font?

Decoy font is a font that prints a decoy for every letter, making it more difficult for AI to read what you type. The font works by using separate spatial frequencies to communicate two different letters in the same space. The foreground contains thin outlines, while the background is a low-frequency mass that is blurred. When overlaid on top of each other, what you see depends on how you look at the letter. If you’re having a hard time seeing the hidden message, move your screen farther away, or try squinting to see it.

Decoy font says two different things from up close vs far away

Decoy font says two different things from up close vs far away

Most AI systems work by reading the pixels of an image up close. So when this type of image is pasted into an AI model like ChatGPT, even when the text is small, the llm focuses on the foreground text because that is what is most clearly outlined. However, from a slightly zoomed out distance, the text reads the actual hidden message. This simple illusion is enough to trick even more advanced LLMs like GPT Sol and Gemini 3.5 with Thinking:

Screenshot of an AI reading the Decoy Font image as BINGE SHOWS

ChatGPT struggles to read the actual hidden message when passed a single image.

Screenshot of Gemini reading the Decoy Font image as BINGE SHOWS

Gemini 3.5 with Thinking also fails to read the actual message.

Decoy Font also exists as an actual TTF font file that can be installed and used to write complete text. You can download and install the TTF font file here. The following paragraph is written in Decoy Font - you can actually copy the text and paste it into your own notepad.

This sentence is written in Decoy Font

Funnily enough, when we pass a screenshot of this font into ChatGPT, it fails to read it properly, even though it might really clear and obvious to you.

Screenshot of ChatGPT failing to read Decoy Font

ChatGPT fails to read the Decoy Font properly from a screenshot.

Decoy Font is constructed based on the technique behind hybrid images. This technique has been well studied for many existing optical illusions. One of the most famous is the image of Albert Einstein and Marilyn Monroe mixed together. Decoy Font applies this same idea to typography as a way to protect the words that you are typing from AI or OCR techniques.

Hybrid image that appears as Albert Einstein up close and Marilyn Monroe from a distance

The famous hybrid image of Albert Einstein and Marilyn Monroe

We've applied this idea of spatial frequencies to create a new font that can used to obscure your writing from AI scrapers.

Anti-AI Fonts

As AI becomes more and more capable at reading text online, there's been a rising interest in protecting information and IP from it. Anti-AI fonts can help help with obscuring text in images and ensuring that messages that are meant for humans are only read by other humans.

Decoy Font is one of the many experiments at Mixfont that explores this initiative. One of our other explorations in this vein is Ghost Font, another anti-AI font that hides a message in motion. However, where Ghost Font relies on a animation to disguise its message, Decoy Font works as a direct TTF font file and can be directly typed in projects.

Decoy Font is an interesting way to obscure messages, but it's not a guarantee. Models with powerful agents and coding abilities may be able to see past the initial lettering, and of course with some basic prompting, certain agents should know to look for the hidden letters. However, Decoy Font still serves as a initial point of confusion for AI, which can make it very effective at deterring scraping or casual observation.

What's next?

If you're interested in exploring Decoy Font further, you can download the TTF font file and use it in your own projects. You can also use the playground above to test out the spatial frequency technique used by Decoy Font on different letter combinations. Then, take your creations and send them to your favorite frontier LLM to see how well it works to decipher the hidden message.

Because Decoy Font can be downloaded and used as a TTF font file, I believe this makes the idea of obscuring text with AI much more accessible to the public (compared with other techniques that require motion and video). It would be interesting to see how this could be applied to technologies like captcha, or just simpler things like sending private messages between friends.

Using Decoy Font as a benchmark of text recognition LLMs would be interesting. As the intelligence of these frontier models improve, they would more and more be able to understand the techniques behind the illusion and decode both messages.

It would be a fun project to extend Decoy Font to support more languages. I believe that character based languages like Chinese would potentially benefit even more from this technique, as the characters are all roughly the same size and shape, which would make it more easy to hide a hidden message.

At Mixfont, I'm building a frontier AI font generator and I'm always interested to explore new ways that typography and AI intersect. I would love to hear your ideas on Decoy Font and how it can be improved. You can find me on X at @ericlu. Thanks for reading!

The Daily Front Page 8 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Detective Work for Synthetic Prose
article

Detecting LLM-Generated Texts with “Classical” Machine Learning

by uneven9434·▲ 237 points·173 comments·blog.lyc8503.net ↗
Mainstream LLM-generated text exhibits strong statistical patterns.

This article is currently an experimental machine translation and may contain errors. If anything is unclear, please refer to the original Chinese version. I am continuously working to improve the translation.

TL;DR & Demo

As of early 2026, mainstream LLM-generated text exhibits strong statistical patterns that can be effectively distinguished from human-written content using traditional machine learning models. I suspect this is how many so-called “AI plagiarism checkers” actually work under the hood.

Online Demo: https://lyc8503.github.io/AITextDetector/

The model used in this demo is not trained on general-purpose data, nor has it undergone rigorous optimization or iteration. Its single-sentence detection accuracy is approximately 85% on the test set. Please read through this article before use to understand potential limitations.

The core code (drafts) and trained model files are available on GitHub: lyc8503/AITextDetector

Background (aka Useless Rambling)

Back when I was still writing my thesis at school half a year ago, rumors were already spreading about checking papers for AIGC (AI-generated content). I tested several platforms—CNKI, Wanfang, and some third-party AIGC detection services—and found they could indeed distinguish between my hand-written text and LLM-generated text with decent accuracy.

That sparked my curiosity about how AIGC detection actually works(and how to bypass it).

But I was juggling too many things at the time—obsessed with radio, Minecraft, Touhou—and after a few failed attempts, I shelved the idea.

Eventually, I faked my way through the thesis, and life moved on. But recently, while browsing Lofter, I stumbled upon entire tags flooded with low-quality, wildly out-of-character AI-generated fanfics.

How can I tell they’re AI at a glance? Well, some folks(or gals) don’t even bother cleaning up Markdown formatting or AI-generated section headers before posting—and then they slap half the article behind a paywall 😓

Most AI-generated texts, however, are harder to spot—they’re buried among diverse writing styles, varied prompts, and not immediately obvious. By the time you realize something feels off, it’s too late. Some texts are borderline impossible to prove as AI, leaving me paranoid. After swallowing a few too many AI-generated turds, I’d finally had enough. Lofter browsing stops here—time to open VS Code!

Yes, that’s how I ended up reviving my weekend project idea: building an AI-generated text detector…

Research Attempt – No Luck

The internet is now almost entirely polluted with ads when searching for AIGC detection. Every result is just another essay-AI-rewriting service. Back then, I dug through the noise and found something called text perplexity.

The idea is simple: use an existing LLM to estimate the probability of each word appearing in a given sentence. If nearly every word ranks high in the LLM’s predictions (Top-N), the sentence is likely AI-generated. Conversely, if many words are unexpected, it’s more likely human-written.

Sounds promising, right? I spent some time trying this method, but results were disappointing—plenty of false positives and false negatives, and no reasonable threshold could be set. Plus, there are practical issues: high inference cost, poor cross-model generalization, difficulty deploying large models locally, and closed-weight models being hard to integrate. Overall, this approach isn’t elegant or reliable.

Failed attempt—got tricked by a bunch of soft-ad "tutorials"Failed attempt—got tricked by a bunch of soft-ad "tutorials"

A (Somewhat) Successful Attempt – scikit-learn SVM

Since online resources were useless, back to old-school alchemy.

Scikit-learn, activate! Following its Roadmap, we can directly pick Linear SVC and Naive Bayes as good starting points for our classification task.

(Whisper: this also matched my gut feeling—LLMs have detectable word-choice patterns; even a Naive Bayes classifier should pick them up. I just didn’t expect the signal to be this strong.)

Data Generation

Old-school alchemy traditional classifiers need labeled data—so we need human-written texts and confirmed LLM-generated texts for training.

My approach: I pulled data I’d scraped in 2023 from a certain Ford-like and River-like platform, filtering for articles published between 2010–2022 (pre-ChatGPT). I only filtered out extremely low-engagement or very short pieces, then randomly sampled nearly 10,000 multi-thousand-character texts as human-written samples.

Then, I used an LLM to generate chapter summaries of these texts, fed the summaries back into the LLM, and had it regenerate full articles. This gave me a roughly equal number of LLM-generated samples, diverse in genre and closely matching the original human content.

In theory, at least. But LLM APIs are expensive, and I wasn’t about to spend thousands on a weekend project. So I got creative—and skirted the rules leveraged multiple low-cost or free API channels:

  • Gemini: Used CLIProxyAPI to convert Antigravity/Gemini CLI quota into API access—just pay ~$20 for an AI Pro account
  • Qwen: qwen-code lets you reverse-engineer the Qwen Plus API—free
  • GLM-5: Lucky timing—OpenRouter was offering free GLM-5 public beta (Pony Alpha)
  • Kimi, Deepseek, Doubao, GLM-4.7: Signed up during a promotional coding plan—$8.9 first month, API access unlocked

Disclaimer: This is not a recommendation. These actions violate platform ToS and may get you banned. But the platforms are too busy with marketing hype to care, and I wasn’t about to pay full price.

Many programming-focused LLM APIs strangely charge per call, but we can abuse optimize this by batching tasks into massive inputs, forcing the LLM to generate more content per call. And so…

What do you mean I used over 300M Gemini tokens worth $2000 at full price?!What do you mean I used over 300M Gemini tokens worth $2000 at full price?!

Ultimately, I used gemini-3-flash to generate summaries, and seven different models (gemini-3-pro, qwen-coder-plus, glm-5, glm-4.7, kimi-k2.5, doubao-seed-code, deepseek-v3.2) to generate seven sets of LLM-generated samples.

The generated filesThe generated files

Training

While I was halfway through data generation, I couldn’t wait and started training.

I asked Claude to write the classifier code, and it naively dumped the entire raw text into the model—achieving a suspicious 99.45% accuracy… Wait, really?

Claude’s useless. I’ll do it myself. For training, I split all texts into sentences using Chinese punctuation, cleaned non-Chinese/English characters, then applied scikit-learn’s TF-IDFLinearSVC. After cleaning up some noise, sentence-level classification still hit ~85% accuracy!

Even this buggy first version hit 88% accuracy (later optimized to 85%)Even this buggy first version hit 88% accuracy (later optimized to 85%)

Individual sentences carry limited info, but 85% accuracy per sentence means that for a longer article, we can be highly confident in judging whether it’s AI-generated. This performance far exceeded my expectations. Old-school ML still slaps—way better than those dumb online tools that just ask an LLM, “Hey, is this text AI-generated?”

After finishing all data, I tried training an 8-class model (human + 7 AIs), but the LLMs seem too similar—probably distilled from each other—so classification was messy, with only ~50% accuracy.

Multi-class results—apparently not separable. Maybe my model sucks, but whatever, not importantMulti-class results—apparently not separable. Maybe my model sucks, but whatever, not important

Eventually, I trained seven separate binary classifiers and used majority voting: a sentence is flagged as AI if ≥2 models detect it.

loaded 8536 samples
train chapter size: 6820, test chapter size: 1716
[gemini] Train: 917,374  Test: 228,051
[gemini] full TF-IDF + SVC ...
  3,336,446 features -> acc=0.8809  f1=0.8082  [tn=143688 fp=10650 fn=16503 tp=57210]
[qwen] Train: 1,315,338  Test: 328,636
[qwen] full TF-IDF + SVC ...
  3,989,603 features -> acc=0.8911  f1=0.8974  [tn=136293 fp=18045 fn=17739 tp=156559]
[pony] Train: 1,128,044  Test: 278,663
[pony] full TF-IDF + SVC ...
  3,688,143 features -> acc=0.8493  f1=0.8286  [tn=135085 fp=19253 fn=22755 tp=101570] 
[kimi25] Train: 1,088,007  Test: 269,567
[kimi25] full TF-IDF + SVC ...
  3,976,027 features -> acc=0.8721  f1=0.8473  [tn=139390 fp=14948 fn=19534 tp=95695]
[glm47] Train: 1,124,430  Test: 279,109
[glm47] full TF-IDF + SVC ...
  3,980,772 features -> acc=0.8436  f1=0.8222  [tn=134461 fp=19877 fn=23786 tp=100985]
[doubao] Train: 1,063,395  Test: 264,121
[doubao] full TF-IDF + SVC ...
  4,243,728 features -> acc=0.8940  f1=0.8700  [tn=142420 fp=11918 fn=16089 tp=93694]
[deepseekv32] Train: 1,176,294  Test: 289,042
[deepseekv32] full TF-IDF + SVC ...
  4,361,691 features -> acc=0.8529  f1=0.8403  [tn=134625 fp=19713 fn=22819 tp=111885]

=====================================
  SUMMARY
=====================================
  model            s1 acc   s1 f1
  gemini           0.8809  0.8082
  qwen             0.8911  0.8974
  pony             0.8493  0.8286
  kimi25           0.8721  0.8473
  glm47            0.8436  0.8222
  doubao           0.8940  0.8700
  deepseekv32      0.8529  0.8403

All models achieved over 85% accuracy and over 80% F1—pretty solid! I also noticed that AI-generated texts were often flagged by multiple models, so voting made perfect sense.

I tried MultinomialNB and SGDClassifier, but accuracy dropped slightly. BERT gave a minor boost but required too much GPU time—discarded. Even tested AutoGluon, which somehow managed only 53% binary accuracy. Won’t dive into those.

JS Implementation for Web Demo

At this point, I could’ve just published the repo and called it a day. But launching Python every time is way too inconvenient. I could’ve hosted a Python API, but that means server maintenance—violates my strict Serverless philosophy.

My original plan: export model to ONNX, run inference via ONNX Web Runtime in Wasm. But when I asked my silicon servant Claude to help, I didn’t specify clearly—and it went off-script, trimming and exporting the model as a JSON… then implemented TF-IDF + SVM entirely in JavaScript for browser inference.

Hmm… actually not a bad idea. I tested it on a 1-million-character text—it took about 10 seconds on my machine, acceptable. For typical few-thousand-character inputs, it’s instant.

Fine, since this is just a demo, and the JS approach is more transparent, I’ll keep this slightly silly implementation. (Blame Claude, not me.)

As for accuracy: I tested different feature limits. Ultimately prioritized performance and kept 500k features. Stored as JSON, it’s a bloated 107MB (though gzipped server-side, it’s ~38MB). Smaller versions (50k–80k) only lost 3–4% accuracy, but final AI detection rates varied significantly—especially on human texts, with ±50% relative differences, leading to false positives. So I stuck with 500k.

Final accuracy drop: ~1%, as shown below:

============================================================
  SUMMARY  top-500,000  C=1.0
============================================================
  model            s1 acc   s2 acc     Δacc    s1 f1    s2 f1      Δf1
  gemini           0.8809   0.8721  -0.0088   0.8082   0.7986  -0.0096
  qwen             0.8911   0.8819  -0.0092   0.8974   0.8886  -0.0088
  pony             0.8493   0.8383  -0.0109   0.8286   0.8173  -0.0113
  kimi25           0.8721   0.8623  -0.0098   0.8473   0.8376  -0.0097
  glm47            0.8436   0.8311  -0.0125   0.8222   0.8097  -0.0125
  doubao           0.8940   0.8869  -0.0071   0.8700   0.8624  -0.0076
  deepseekv32      0.8529   0.8419  -0.0110   0.8403   0.8291  -0.0112
  AVG                       0.8592                     0.8348
  MIN acc: 0.8311
============================================================

Testing Performance

All tests below use the pruned web version, which should perform similarly to the full joblib models.

Current logic: split input text into sentences, clean and classify using all 7 binary models. If ≥2 models flag a sentence, it’s marked as suspected AI and highlighted. Final AI score is the proportion of flagged characters. Classification:

  • <50%: Human
  • 50–70%: Maybe Human
  • 70%: Maybe AI

First, test detection rate on common models like Doubao and Deepseek—both were in training data. Prompt: write me a 3000-word story. Easily caught:

Deepseek V3.2: 78.4%Deepseek V3.2: 78.4%

Doubao Seed Code: 93.0%Doubao Seed Code: 93.0%

Now test on unseen models—how’s generalization?

Claude Sonnet 4.6: 71.9%Claude Sonnet 4.6: 71.9%

GPT 5.2: 73.3%GPT 5.2: 73.3%

I tested several other models not in training (MiMo-V2, Doubao-Seed-2.0, GPT-4o)—all detected at ~70%, some even >90%. Solid.

Also tested more complex prompts—e.g., feeding 20 chapters of human-written text and asking the LLM to mimic style and continue. Detection rate dipped slightly to 67.8% (but remember, we trained on complex prompts too). Results not shown due to space.

Then I picked 10 completed web novels (pre-2022) from my subscription list—diverse genres, authors, eras, and likely not in training data.

Their AI detection rates: 22.7%, 24.2%, 25.0%, 24.5%, 19.0%, 13.7%, 29.1%, 4.9%, 27.3%, 19.2%—all under 30%. I also sampled random Lofter fanfics; since they’re more casual, detection rates were often below 10%. But when I fed in texts I suspected were AI-generated, detection spiked to 83.4%, strongly suggesting LLM use without disclosure.


[Mar 5, 2026 Update] For more rigorous testing, I randomly sampled 10,000 high-engagement (views >5000), long-form (word count >2000) fanfics from Lofter, all posted before 2022. Their AI detection rate distribution (using 7-model voting, ≥2 votes):

0-5%:313 | 5-10%:1945 | 10-15%:3016 | 15-20%:2033 | 20-25%:1355 | 25-30%:594 | 30-35%:492 | 35-40%:123 | 40-45%:34 | 45-50%:62 | 50-55%:24 | 55-60%:5 | 60-65%:3 | 65-70%:1

Using 60% as threshold → false positive rate: 0.04%
Using 70% → false positive rate: <0.01% (effectively zero)
The four texts above 60% were all collection indexes, not actual stories—flagged due to excessive links.

Even at 50% threshold, false positive rate is only 0.33%.

Then, I scraped all articles from Lofter Android’s top 20 trending tags (weekly榜单), filtered by length, and ran detection:

0-5%:27 | 5-10%:138 | 10-15%:231 | 15-20%:245 | 20-25%:238 | 25-30%:137 | 30-35%:112 | 35-40%:87 | 40-45%:116 | 45-50%:112 | 50-55%:118 | 55-60%:157 | 60-65%:118 | 65-70%:109 | 70-75%:75 | 75-80%:56 | 80-85%:28 | 85-90%:15 | 90-95%:10

32.22% of articles scored >50% AI—likely partially or fully AI-generated… Is there any human left?? Moreover, not a single one has proactively declared AI-generated content.

“Age of Dharma’s decline, , ,” —a friend in the group

Attack and Defense – Bypassing Detection(?)

Alright, we’ve built an AIGC detector. Time to build an anti-detector now.

Nope, kidding. I’m not that bored.

But let’s test some common anti-AIGC detection tricks:

Classic Translation Method

Google Translate roundtrip (CN→EN→CN): 89.9% → 85.0%
Youdao Translate (CN→EN→CN): 89.9% → 79.2%
Sogou Translate (CN→EN→CN): 89.9% → 86.0%

Slight drop, but still clearly flagged.

LLM Prompt Method

Use “magic” prompts to make LLM output less “AI-like”—sounds ridiculous from the start!

Tested one-line prompt: Rewrite the above article to minimize AI flavor: 89.9% → 83.0%

Also tried more complex prompts: 89.9% → 79.3%

Slight improvement, but still meaningless in practice.


This detection method is way too robust!(flag

If I really wanted to bypass it, my only ideas would be fine-tuning an LLM on massive human text, or building a huge rule-based system to surgically disrupt SVM-matched features. But that’s beyond this article. Not sure if it’d even work. Or maybe there are better ways—left as an exercise for the reader.

Epilogue

Now, time for closing rambling.

I honestly didn’t expect this classification task to be so easy—simple enough that a scikit-learn “Hello World” script, with minimal iteration and some hardcoded rules, could produce a fairly robust and accurate detector. Most of the effort was just waiting for LLMs to generate data…

Ambitious readers could follow this approach to train detectors for other domains—say, academic paper AIGC detection. Add a flashy frontend, and you’ve got a tool you can sell to desperate college students. If you make money, don’t forget to donate a little.

Another idea: detect AI-generated images. But with Stable Diffusion and easy LoRA fine-tuning, visual styles are far more diverse than text—this task would be much harder. And after writing this, my three-minute enthusiasm is burned out. Maybe next time.

Lastly, a few words on AI-generated content: I don’t accept AI-generated entertainment as legitimate creative work. Just like AI coding tools produce bloated, unmaintainable code, AI-generated text, images, and audio may seem decent at first glance, but fall apart on closer inspection—repetitive, shallow, and so predictable that even word frequency stats can catch them. This pattern is fundamentally unsuitable for real creation, and as a reader, I’m deeply unsatisfied. I’m starting to suspect LLMs’ so-called “creative writing” is just a bunch of post-training data being endlessly recombined and regurgitated.

But then again, “the world should be” has never equaled “the world is.” While LLMs bring innovation and productivity gains, misuse and abuse are spreading relentlessly across every industry. And since LLMs are fine-tuned to exploit human perception, who knows whether they “understand” anything or just memorize patterns? After patching endless bugs like “which is bigger, 3.9 or 3.11” or “should I walk or drive 50m to a car wash,” can we really say the model understands the world?

Everyone’s stuck in debates: What is LLM? How will it disrupt my industry? Where will AI take humanity? No one has answers.

At least I’m glad I learned to code before the AI era. Otherwise, I might not realize how stupid today’s vibe-coded software really is. As for the future? Either generative AI smashes human social order to pieces, or the AI bubble bursts and memory becomes free. Either way, sounds fine to me, doesn’t it?

The Daily Front Page 9 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — The Uneasy User
article

The LLM Critics Are Right. I Use LLMs Anyway

by JeremyTheo·▲ 290 points·292 comments·theocharis.dev ↗
I almost agree with all of the LLM critics, yet I still use LLMs a lot.

I almost agree with all of the LLM critics, yet I still use LLMs a lot. I know this sounds like I am delusional, and I also feel like that sometimes because of this dissonance, but I don’t think I am alone with it.

This week I was at Local-First Conf in Berlin, and the dissonance was everywhere. Armin Ronacher had just given a talk about building machine entities. He created Flask and was one of the early team members of Sentry, so he is clearly a good software engineer. Just recently he founded his company Earendil, which builds Pi.dev, an “open-source coding agent harness”. After the talk you could ask questions via Discord, which would be read out loud on stage, and I asked:

are you accepting PRs for Pi, or how do you handle the flood of PRs from LLMs?

He replied, live on stage, in front of the entire audience, that they auto-close almost all PRs and issues. But that one shouldn’t be discouraged from opening PRs, because the human will always shine through.

So it is not only me, but apparently some pretty clever engineers too. The people building a tool for working with LLMs are themselves flooded by their own creation, and in order to protect themselves they are auto-closing it all. On their purpose page it says:

In a world hurtling towards AI, we believe humans are the best agents.

Again, dissonance.

When I was sitting in the audience, I could see a lot of people having Claude Code open. And then the speakers would say these critical things about LLMs, and they would get this big round of applause. Even from the people with their Claude Code open.

And again, this dissonance.

I spoke at that conference myself, and when I later talked to some of the people, they described the feeling as pretty similar to mine, which is a relief, because I know I am not alone with this.

So this article is me trying to describe it. I’ll start by going through all of the fair and valid concerns about using LLMs, the things that would get the big round of applause. Then I will explain what makes me still use LLMs. And I’ll finish up with some of the patterns I found, in the hope that by giving concrete examples, others can step in as well and describe their experiences, so we can all come together and get a better understanding of this dissonance.

A few impressions from yesterday‘s talks 📸

@adamwiggins.com @martin.kleppmann.com @stevenruiz.bsky.social @jakelazaroff.com

— Local-First Conf (@localfirstconf.com) July 13, 2026 at 3:44 PM

LLMs are bad

Just by listening to people or the talks or reading HN, I think I got a pretty good sense of why certain people refuse to use LLMs. And what makes it extremely weird is that I agree with almost all of their points!

It is full of copyrighted materials, yes. It is bad for the environment, yes. All the ethical problems, yes. And this whole NVIDIA, OpenAI, money-moving circle-jerk is not going to end well. It is a bubble, and it is definitely going to burst.

Let me go through the biggest ones one by one.

Let’s start with the most common critique “LLMs produce a lot of slop”.

Yes, they do. Definitely.

If you look at open source software, you will see more and more repos and projects either straight up refusing all types of contributions or trying to put some kind of filters in front of it. Similar to what Armin and Earendil are doing with their auto-closing.

I think the core issue here is trust. You should never trust random people on the internet anyway. But before LLMs, there was this base thing: creating a proper PR with proper descriptions would require at least some human time, so it would keep trolls and low quality submissions out. Or at least you could easily filter them out within a couple of seconds. So even if a new person came in, you could trust that this person would have at least spent a couple of hours on that. And then it was probably worth taking a closer look at it.

That base thing is not true anymore. Everyone can simply create a new GitHub account and let their LLM loose, and as a maintainer you cannot easily tell whether someone put a lot of time into the PR (and maybe used Claude for just the PR description), or whether it is just an OpenClaw machine acting on its own. Projects like Zig or Gentoo are already refusing to accept LLM generated PRs (which I don’t think is the solution, because how would you even tell?)

I think LLMs might have serious potential to kill OSS, if we don’t find ways to restore that trust. One idea could be to only allow a small set of verified people to contribute to a project, and in order to get verified you would need to go to a real-life meetup or something.

And then there is the situation about junior engineers. There are actually two different points in there: a) you cannot trust the effort behind your junior’s code anymore, and b) seniors have no incentive left to teach juniors.

Let’s start with a): Senior people have always corrected and fixed the code of junior people. And juniors have always written some pretty bad code (my worst code was written in the before-LLM times). It is just now that as a senior while reviewing you don’t know if that junior just vibecoded it in 10 minutes, or if he sat there for a couple of hours but is genuinely lacking some good insights.

And b), the teaching, aka “How do we teach new people?”: previously, there was this balance aka “the junior does some pretty mundane tasks, but for this the senior reviews it together with him and helps him to grow”. Now as a senior, you don’t need juniors anymore. The mundane tasks, at least I find that a lot of people agree with that one, can be fully outsourced to an LLM. So why hire juniors at all?

And then there are the geopolitical tensions. What happens if China or the US cut us off overnight from these technologies? Just a couple of weeks ago the US government showed it was able and willing to cut off non-US citizens from Anthropic’s latest frontier model.

Anthropic's announcement from June 12, 2026: Statement on the US government directive to suspend access to Fable 5 and Mythos 5. The US government issued an export control directive to suspend all access by any foreign national, forcing Anthropic to abruptly disable both models for all customers.

Anthropic’s own statement, June 12, 2026: a US export-control directive forced them to abruptly disable Fable 5 and Mythos 5 for all customers.

I don’t want to be a doomsayer here. I think Martin Kleppmann described it on stage in his talk pretty well:

the probability of a conflict between Europe and the US is still very low. But last year, it was zero.

Last but not least, even when just researching with LLMs, they have the natural tendency to silently sneak in the thoughts of the majority of the training materials, or sometimes even the political convictions of the ones who created the model.

It is like two humans talking to each other: over time, their opinions will slowly merge. Ever noticed that one single friend who just keeps using this one weird word, and then a couple of weeks later your entire friend group is using it? It is like that, just with opinions.

And one of the participants of the conversation is not a human.

LLMs are good

But we cannot make them go away entirely anymore. They are here, and they are here to stay. And instead of going against the flow, we can go with the flow, and then control it and shape it as well.

For example by ensuring models can run on your laptop. They are already getting better, and they make us programmers independent of these big corporations. And when the subsidies end and the prices rise, it is the open-weights models that keep the large vendors in check. And a model that runs locally on your own hardware cannot be cut off overnight by any government either. I would even make the argument that when the bubble bursts, there is going to be a lot of damage to the world economy, and a lot of companies will topple. But the open weights models are not going anywhere, so we programmers can fall back on them. Even at the conference, the talks that were about AI mostly took local models seriously.

Imagine: you would have this Sci-Fi AI running in the background, and you could always ask it questions, and it would always answer you. Like in Star Trek or something.

In many of the talks, AI appeared only as an aside: “we built this with Claude Code.” Some of the speakers even openly said “yeah, I just gave it to Claude Code”. But their talks got accepted, and they got a big round of applause from the audience, including from some pretty senior and well-respected people in there.

The main thing is that there are humans who put their credibility on the line. And THAT makes you listen. If they presented something with AI slop they would lose their credibility. And I think that makes them use AI differently. They say: “I just asked Fable 5 to implement it.” It sounds like a tech-bro. And it is exactly how I would describe it too. But I am guessing here, and I can only speak for myself: they are not letting the LLM do the thinking. It is their thoughts, now supercharged and put on crack.

LLM’s amplify what you already have: opinions, structure, frameworks. If you have thoughts, they come out sharper and faster. They are good at helping with brainstorming, checking your grammar, iterating on sentences, giving alternatives, acting as a rubber ducky or a devil’s advocate. If you have nothing, nothing comes out, very fluently. LLMs are good at producing massive amounts of content that looks good, but that nobody would read out loud in front of an audience.

And this is where the value is for me: I can simply make things higher quality than I could do them alone. I can do way more with it, but I use it to make fewer things more high quality. I tend to use an extreme amount of tokens, just to prepare a couple of sentences for a human. I think this is a good use of LLMs. And I find very much that LLMs can support you in thinking.

I strongly agree that written text should be from humans to humans. Yet I still write all of my texts with LLMs. And I don’t find that contradictory. What distinguishes “AI slop” from “good writing” is whether a human has put thoughts behind it. And you cannot outsource thinking.

But here is the problem: whether a human has put thoughts behind it is exactly the thing you cannot see from the outside. The sentence “I use AI to think better” is word for word the same coming from me and from some random AI tech-bro, and there is no way for you to tell, based on what I am saying, if it is bullshit or not. You simply cannot. Since amplified bullshit sounds like genius, all that’s left is trust. And trust is difficult to gain, and easy to lose. Especially in the era of LLMs. I see it myself: a single em dash can somehow invalidate the entire thing, as I catch myself thinking, didn’t the person want to at least remove the obvious signs of AI slop?

In the conference Discord, during one of the talks, someone asked how the others deal with the tension: a lot of people who are politically interested in local-first are also staunchly anti-LLM. Did it worry them to make software with LLMs, knowing it might get rejected by the community? Someone answered that a number of speakers, including the one on stage at that moment, had been saying “complicated thoughts” about exactly this. And I think that says a lot: even here, people feared saying out loud that they use LLMs. I am a little bit scared to say this too: last month I spent almost 10k USD on tokens. It sounds so insane.

Spreadsheet screenshot of my June 2026 token spend by model, in USD: Opus 4.8 at 5042, Fable 5 at 4179, Sonnet 4.6 at 452, grand total 9838.85

My June 2026 token spend by model, in USD, straight from the spend report. I know it was extreme. I have since changed two things: I use Fable only very selectively now, it is just too expensive. And for pure code execution I work with OpenRouter and cheaper models like GLM 5.2.

But one can really get a lot of value from it, and I think it is worth it.

When AI was new and I was trying it out, I partly ruined my own credibility with it. But then I realized that credibility is how you earn the trust: would you stand in front of an audience and read it out? If the answer is “well, I would explain what was meant by it”, then it is slop. If you would actually read it out, word by word, and not be ashamed of it: congratulations, it is a good text.

Some of the patterns I found

So how do I use them? There is this unexplainable fine line that is hard to describe, and I think it can only be experienced, learned from using these tools with an open mind. I know how this sentence sounds. It is exactly what the tech-bros say. I cannot even invite you to try it without sounding like them. So I will try to describe some of the patterns I found. Not as a “this is how to do it,” but in the hope of clarifying the situation.

Yes, it will write bad software if it doesn’t understand the actual problem and the requirements. With the right skills and tools, you can produce some pretty decent software. But between you and that decent software stands agreeableness: it will not tell you when it didn’t understand something, and just go ahead and do something. This is also why the /grill-me skill, adapted from Matt Pocock’s “grill me” technique, is so extremely powerful. It is extremely short:

Interview me relentlessly about every aspect of this until we reach a shared understanding. Walk down each branch of the decision tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.

Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering.

If a fact can be found by exploring the environment (filesystem, tools, etc.), look it up rather than asking me. The decisions, though, are mine — put each one to me and wait for my answer.

Do not act on it until I confirm we have reached a shared understanding.

And honestly, the grill-me feels good. When I found this skill, I was absolutely excited about how something so simple can have such an impact. Because it forces you to form your own thoughts, question by question. I have since adopted this step-by-step approach for everything, this spending-an-extreme-amount-of-tokens-on-a-single-sentence kind of approach, for example for writing articles. This article here was written by me chaotically writing down my thoughts, and then going sentence by sentence, getting roasted by the LLM.

Whenever I code something, even for minor things, I follow the Basecamp “Pitch” and think really, really hard about a short “Problem,” “What we are shipping,” “What we are not shipping.” I force myself to write a problem statement of three sentences. It is incredibly easy, and even easier with LLMs, to fill this out. But it is incredibly difficult to make it good. I think this works so well in an LLM workflow precisely because it was designed for humans: three sentences force me to actually read them. And if I read them, another human can also read them.

I tend not to actually read most LLM output anymore; I skim it, to check if I vibe with it. But a problem statement of three sentences, that I will fact-check really hard. It is like code review: a review with 1,000 lines of code gets an “LGTM”. A review with 100 lines gets 15 comments.

The same care goes into my PR descriptions: I spend a lot of time on them, to ensure they remain readable, are concise, and always describe the actual problem, what we are shipping, and what we are not shipping. And I add screenshots of it working, to give the reviewer an obvious sign that it is worth interacting with this PR, as it is clearly working. But I have to always fight with Claude about the descriptions, as it keeps adding slop to them. And sometimes I just let Claude win too much there, if it is not an important PR.

You need something against them, to counter the large amount of stuff they create. In my coding workflow I put in smaller agents that counteract that.

Another technique I like (the Ralph Wiggum loop, or more modernly Claude’s ultracode) is to lock the LLM in with a text or plan or statement or code or whatever, and keep spawning subagents (LLMs with a fresh context) with their only task to rip apart the context. And they keep doing it, until they are forced to hallucinate problems. And only then do I continue with the workflow.

If they are forced to hallucinate, you can actually use their weakness: they are trying to agree with you that there are problems in there, but they cannot find any.

Especially if you combine it with the /grill-me skill, you end up with a result where you as the human have no further comments or thoughts, not even the most minor thought. The LLM has tried ripping apart your thoughts as well, and sometimes roasted you for hours, and forced you to form your own thoughts.

There is even a way to use the hallucinating itself: at last year’s conference, Anselm Eickhoff described how you can let an LLM hallucinate the API or the UX it expects, before showing it the real thing. Whatever it guesses is probably what most humans would guess too. So instead of fighting the hallucination, you use it: as a cheap test of whether your design matches what people expect. I have built this into a skill as well, if you want to try it.

Screenshot of the intuition-probe skill on GitHub: let an LLM hallucinate the API or UX it expects, then conform your design to the guess. A blind agent commits the design it expects before seeing the real thing, then reports the shape it reached for.

My intuition-probe skill: a blind agent commits the design it expects before seeing the real thing. Free to grab from the gist.

All of these patterns have one requirement, though: I need to be able to tell whether the result is any good. And the more LLMs I use, the more I end up in fields I do not know well. And then I need experts. Because I can only program things if I understand them. It is like delegating: I only delegate a task to another member of my team if I at least understand the basics and know what good is. Same for LLMs.

If I know something very well, I can quickly tell “good” and “absolute dogshit” apart. And if I know something not well, I use LLMs only to help me learn it. Because if I use them while I cannot yet distinguish good from dogshit, I will end up with full-blown slop production.

There are two ways to learn, based on whether the result can be clearly differentiated as good or bad. Good or bad as in: the code compiles or it does not, the test suite is green or it is not, the protocol decodes or it does not. Not as in “the code is good,” because code quality is a thing. In fields with such a clear check, you can point the LLM at it and learn together. One guy at the conference described how he reverse-engineered binaries and protocols using Opus 4.6, and the only thing he needed was basic knowledge of reverse engineering. Either his patched binary worked, or it bricked the device. It was clear whether the result was correct or not, so he could even find his own techniques along the way.

In fields where you have a lot of opinions, like programming, the LLM will just tell you what most people would want: the most popular technique, which is maybe not the best one for your case.

We had a team discussion once, where people were telling me code was AI slop, and that it was a general AI problem. When we went into it, it turned out they didn’t like TDD. But we had another one on the team who did a lot of TDD long before the age of LLMs. So suddenly, AI slop wasn’t about AI slop anymore. It was about humans having different opinions. And like I said, LLMs only amplify. With them, you can amplify your opinion. Here you need humans, to point you roughly in the right direction and give you good starting opinions, which you use until you are able to continue on your own.

I am not alone

I hope that you can trust me, and I fully understand if you don’t. The only way for you to really know is to engage with my content, but that takes a lot of time, so I understand if you don’t. But if you are still here at this point, you have already engaged with this text for quite a while. And maybe you already know that a lot of effort went into this article, and that it is not some obvious slop.

I am not alone with this dissonance.

Writing alone did not show me that. But while writing, I looked through the Discord of the conference, I asked people at the conference directly, and I went through the HN articles of the past weeks. That showed me: other people, describing the same dissonance. So maybe others can describe their experiences and learning paths as well, to clarify this fine line.

Somewhere in all of this is this very good stuff. Not the hype, but a genuinely good tool that enriches your thinking. It will never replace your thinking though.

Go ahead, try it out. I know, that is exactly what a tech-bro would say. I cannot say it any other way.


Thank you to all the people at Local-First Conf. You made my head explode with information.

The Daily Front Page 10 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — The Hundred-Dollar Director
article

$100 AI Music Video: Claude Fable 5 vs. GPT-5.6 Sol

by hershyb_·▲ 386 points·520 comments·tryai.dev ↗
Hand a model a song, a hard dollar budget, and a set of tools, then get out of the way.

We gave Claude Fable 5 and GPT-5.6 Sol the same song, a budget, web search, and local ffmpeg, then let each autonomously direct a music video.

We built a small agentic harness with one job: hand a model a song, a hard dollar budget, and a set of tools, then get out of the way and let it produce a full music video on its own. The model researches which video models exist, generates clips, watches its own footage, edits with ffmpeg, and assembles a final cut.

A few readers of our last build-off said they wanted to see how tool use actually varies between models, so we gave frontier-level models an open-ended, long-horizon task where each model decides on its own what to research, what to generate, and how to edit. We log every tool call, so you can see exactly how each one worked (full transcripts below).

We ran two models, Claude Fable 5 and GPT-5.6 Sol, each at two budgets ($25 and $100), for four runs total. Every run got the same song (Bruno Mars and Mark Ronson's "Uptown Funk"), a short text description, and a time-stamped lyric transcript.

The setup

Each model ran an autonomous tool-calling loop with six tools:

  • plan: a tool for thinking (no cost, no action).
  • web_search: to research generation models and their APIs and fetch information about music videos (if needed).
  • get_budget: to check the remaining budget.
  • generate_image and generate_video: the only tools that spend budget. The model can pick any FAL or Replicate model and pass its own parameters.
  • run_command: a local shell with ffmpeg/ffprobe available, used to analyze audio, cut and concatenate clips, and mux the final video.

Once the budget hits zero, paid generation is refused, but the model can keep editing. Every model message, tool call, charge, and error was logged. The whole harness is open source at github.com/hershalb/music-video-arena, so you can run it yourself.

The four videos

Each clip below is the model's final, self-assembled output.mp4, full length with the original song muxed in.

Claude Fable 5 · $25GPT-5.6 Sol · $25

Claude Fable 5 · $100GPT-5.6 Sol · $100

The numbers

All four runs finished on their own (none hit a step or time limit) and all four produced a valid, full-length video with the original song muxed in.

ModelBudget↕Wall-clock↕Steps↕Images↕Videos↕Failed calls↕Generation spend↕Output↕Claude Fable 5$2539m10s250541$24.301280x720GPT-5.6 Sol$2542m52s38614610$23.181280x720GPT-5.6 Sol$10049m39s340702$36.571280x720Claude Fable 5$10038m56s280800$48.601920x1080

"Generation spend" is the metered FAL cost, which is what the budget caps. At $25 both models nearly exhausted it. At $100 they spent $36.57 (Sol) and $48.60 (Fable), so more budget did translate into more footage. It does not include the cost of running the model itself, which we add below.

Time to finished video

Wall-clock time per run in minutes

What each model built with

Left to choose their own tools, the models diverged. Three of the four runs went pure text-to-video. Only GPT-5.6 Sol at $25 used an image-to-video pipeline (generating stills first, then animating them). GPT-5.6 Sol at $100 mixed three different video models in a single run.

RunImage modelVideo model(s)ApproachFable 5 · $25noneWan 2.5 t2v ($0.05/s)Text-to-video onlySol · $25FLUX schnell ($0.003/img)Wan 2.2-5b i2v ($0.10/s)Keyframe, then image-to-videoSol · $100noneWan 2.5 ($0.05/s), Veo 3.1 Lite ($0.10/s), Hailuo 2.3 Standard ($0.28/video)Text-to-video, mixed modelsFable 5 · $100noneSeedance 1.0 Pro t2v (~$0.12/s at 1080p)Text-to-video only

Prices are FAL's listed rates, shown per second of output video unless noted. Hailuo 2.3 Standard is priced per video (about $0.28 per 6s clip), and Seedance 1.0 Pro is token-priced (~$0.62 per 5s 1080p clip, shown above as its effective per-second rate). Distinct clips generated per run ranged from 46 to 80.

Number of images and videos successfully generated per run

Tool usage

How each run spent its tool calls (this counts attempts, including failed generation calls).

Claude Fable 5 · $25GPT-5.6 Sol · $25Tool-call distribution, Claude Fable 5 at $25Tool-call distribution, GPT-5.6 Sol at $25

Claude Fable 5 · $100GPT-5.6 Sol · $100Tool-call distribution, Claude Fable 5 at $100Tool-call distribution, GPT-5.6 Sol at $100

Each run's full transcript, every plan, tool call, and command, is here: Fable 5 · $25, Sol · $25, Sol · $100, Fable 5 · $100.

Errors along the way

"Failed calls" are generation requests that returned an error (mostly transient network failures to the provider). They were not charged, but the model spent steps retrying them.

Failed generation calls per run

Token usage

RunInput tokensOutput tokensReasoningCached inputFable 5 · $251,476,90044,341n/a0Sol · $252,956,27033,2209,6562,558,029Sol · $1002,097,57231,71512,3301,819,050Fable 5 · $1002,264,61048,029n/a0

Total cost per run

The budget only meters generation (FAL) spend. Adding the LLM token cost for Claude Fable 5 ($10 / $50 per 1M input/output) and GPT-5.6 Sol ($5 / $30), gives the total cost of each run.

RunGeneration spendLLM token costTotal costFable 5 · $25$24.30$16.99$41.29Sol · $25$23.18$4.27$27.45Sol · $100$36.57$3.25$39.82Fable 5 · $100$48.60$25.05$73.65

For Claude Fable 5, the tokens alone ran $16.99 to $25.05, about 30-40% of each run's total. GPT-5.6 Sol's token cost stayed near $3-4 despite similar token volume.

Method notes

  • Same inputs for all four runs: song, a short text description, and a time-stamped lyric transcript. Each model chose its own generation models on FAL and did its own ffmpeg editing.
  • Wall-clock time includes the model's own retries and any waiting on provider queues.
  • Generation spend is a best-effort estimate from a per-model price table.

Try it yourself

The arena is open source: github.com/hershalb/music-video-arena. Point it at your own song and budget, swap in whichever models you want to pit against each other, and see what they build. Issues and PRs welcome, we would love feedback on the setup.

Our take

None of the music videos were great, but watching how the models got there was pretty interesting and does show where gaps still clearly exist for frontier-level models. A few things notes:

  • Character and story consistency was a struggle for all four. Recurring characters drift between shots, and none of the videos hold a coherent storyline from start to finish.
  • The models take lyrics very literally. "Make a dragon wanna retire, man" gets you an actual dragon on screen. It's interesting for a few shots, but got a little weird after a while.
  • Tempo matching is weak. The cuts land on the beat (they all ran the ffmpeg beat detection), but the motion inside the clips, dancing, camera moves, rarely matches the song's tempo, so it often feels a little off. An example line "gotta kiss myself I'm so pretty", shows the main character making a kissing motion way too slowly.
  • GPT-5.6 Sol at $25 was the most inventive editor. It overlaid text and animated still images with video effects, techniques none of the other runs tried. The rest mostly just stitched generated clips together. GPT 5.6 Sol $100 also tried multiple video models instead of just sticking with one like Fable did.
  • Nobody really iterated on the edit. Once clips existed, the models concatenated and muxed, but rarely went back to re-cut or add effects, and none seriously probed their own clips to confirm they were any good. GPT-5.6 Sol's $100 run shipped some genuinely low-quality AI clips, while Claude Fable 5 happened to pick a model with more coherent output. Some of this is probably a model limitation, but the lack of self-review is notable.
  • Neither model touched Replicate. Both FAL and Replicate keys were available, but all four runs used FAL exclusively.
  • Claude Fable 5 was the pricier pick. It cost more per run (and the most overall, at $73.65) despite finishing faster than GPT-5.6 Sol. Subjectively, we slightly preferred the Fable $100 video, though none blew us away.
  • $100 was probably too much budget. Neither model wanted to spend near the cap, and both kept their step counts modest. With that headroom they could have, for example, generated consistent character images up front and animated from those, but neither chose to.

We'll see if models can improve on more subjective/stylistic tasks as they continue to get smarter, but for now there's still a lot of room for improvement.

The Daily Front Page 11 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Local Agents, Cloud Muscle
article

LM Studio Bionic: the AI agent for open models

by minimaxir·▲ 321 points·126 comments·lmstudio.ai ↗
The AI agent made for open models.

LM Studio Bionic

Today, we're taking the biggest leap forward in LM Studio's evolution. Meet LM Studio Bionic, the AI agent made for open models.

Bionic is the AI agent for getting real work done with open models, including coding, research, and complex work with documents and files. You can use local models or switch to open-source models in the cloud for heavier tasks, all while staying in control of your privacy and AI spend.

For all LM Studio Bionic users, we commit to Zero Data Retention and never training on your data.

Bionic brings together:

  • A Bionic agent that excels at coding and document work
  • Voice input with state-of-the-art local voice transcription
  • Flexible model execution: run locally, connect through LM Link, or use the largest frontier open source models through LM Studio Secure Cloud
  • Better cost control by letting users choose the right model and compute environment for each task

Offline voice transcription

Use Bionic's voice keyboard with local transcription to speak through ideas, prompts, and edits - all entirely locally on your device, using state-of-the-art local audio models. For launch, we are shipping Voxtral by Mistral AI. Voxtral is a performant multilingual realtime transcription model.

Bionic voice keyboard transcribing in a notes app

Use Bionic's voice keyboard to dictate into any app with local transcription.

Start the voice keyboard from any app, and Bionic will begin transcribing where your cursor is.

Bionic for Coding

Bionic supports a wide range of coding needs without giving up privacy and control.

Bionic working in a Git codebase

Bionic can inspect local codebases, explain unfamiliar code, and help you make changes.

Create a Code project and point it to a local folder. Ask Bionic to investigate, edit, or debug, and review its work as it goes. Inline diffs make every code change easy to inspect, and with agentic code search, Bionic can quickly find relevant files, trace behavior, and explain unfamiliar code.

Bionic works with powerful open models like GLM 5.2 and Kimi K2.7 Code, so you can build more while keeping costs under control.

Bionic for working with docs, slides, and sheets

Bionic is also built for general productivity and deep knowledge work.

Bionic creating a Paris Girls' Getaway presentation

Give Bionic documents to work with, or ask it to generate new documents, decks, spreadsheets, and more from scratch.

Use Bionic across documents, PDFs, decks, spreadsheets, and more. In a Work project, Bionic processes documents in a sandboxed environment, keeping the rest of your computer and files safe. It can organize local directories, edit files, summarize materials, and bring outside context into your workflow with native web search. Automatic checkpoints let you safely review or roll back changes, while in-app previews keep your materials and workflow in one place. We're continuing to add preview support for more file types, so stay tuned!

Natively Local

Bionic local models library

Download and run local models in Bionic.

Download the latest local LLMs directly within the Bionic app, then use them for simple chats or advanced agentic tasks. Local models in Bionic are powered by the LM Studio runtime.

Cloud inference with Zero Data Retention by default

Bionic cloud model picker

Bionic supports the latest frontier open models for your most complex tasks, running on the LM Studio Secure Cloud.

Bionic is built for a world where open models keep getting better. As frontier open source models improve at coding, reasoning, tool calling, and long-context tasks, Bionic gives you a way to try them in LM Studio Secure Cloud. When using cloud models, your requests are processed transiently and are not retained after the request completes.

Getting started

Download LM Studio Bionic.

Bionic is a new, separate app from LM Studio. For advanced low-level configuration, you can continue to use LM Studio alongside Bionic.

To use cloud models, create an LM Studio account to set up billing for your user.

From there, connect a project, choose a model, and start working with the Bionic agent!


What's next

We'll keep improving the experience as open models become more capable and as we learn from how people use Bionic in real projects.

The Daily Front Page 12 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Command-Line Etiquette
article

Command Line Interface Guidelines

by subset·▲ 168 points·53 comments·clig.dev ↗
Taking traditional UNIX principles and updating them for the modern day.

An open-source guide to help you write better command-line programs, taking traditional UNIX principles and updating them for the modern day.

Foreword

In the 1980s, if you wanted a personal computer to do something for you, you needed to know what to type when confronted with C:\> or ~$. Help came in the form of thick, spiral-bound manuals. Error messages were opaque. There was no Stack Overflow to save you. But if you were lucky enough to have internet access, you could get help from Usenet—an early internet community filled with other people who were just as frustrated as you were. They could either help you solve your problem, or at least provide some moral support and camaraderie.

Forty years later, computers have become so much more accessible to everyone, often at the expense of low-level end user control. On many devices, there is no command-line access at all, in part because it goes against the corporate interests of walled gardens and app stores.

Most people today don’t know what the command line is, much less why they would want to bother with it. As computing pioneer Alan Kay said in a 2017 interview, “Because people don’t understand what computing is about, they think they have it in the iPhone, and that illusion is as bad as the illusion that ‘Guitar Hero’ is the same as a real guitar.”

Kay’s “real guitar” isn’t the CLI—not exactly. He was talking about ways of programming computers that offer the power of the CLI and that transcend writing software in text files. There is a belief among Kay’s disciples that we need to break out of a text-based local maximum that we’ve been living in for decades.

It’s exciting to imagine a future where we program computers very differently. Even today, spreadsheets are by far the most popular programming language, and the no-code movement is taking off quickly as it attempts to replace some of the intense demand for talented programmers.

Yet with its creaky, decades-old constraints and inexplicable quirks, the command line is still the most versatile corner of the computer. It lets you pull back the curtain, see what’s really going on, and creatively interact with the machine at a level of sophistication and depth that GUIs cannot afford. It’s available on almost any laptop, for anyone who wants to learn it. It can be used interactively, or it can be automated. And, it doesn’t change as fast as other parts of the system. There is creative value in its stability.

So, while we still have it, we should try to maximize its utility and accessibility.

A lot has changed about how we program computers since those early days. The command line of the past was machine-first: little more than a REPL on top of a scripting platform. But as general-purpose interpreted languages have flourished, the role of the shell script has shrunk. Today’s command line is human-first: a text-based UI that affords access to all kinds of tools, systems and platforms. In the past, the editor was inside the terminal—today, the terminal is just as often a feature of the editor. And there’s been a proliferation of git-like multi-tool commands. Commands within commands, and high-level commands that perform entire workflows rather than atomic functions.

Inspired by traditional UNIX philosophy, driven by an interest in encouraging a more delightful and accessible CLI environment, and guided by our experiences as programmers, we decided it was time to revisit the best practices and design principles for building command-line programs.

Long live the command line!

Introduction

This document covers both high-level design philosophy, and concrete guidelines. It’s heavier on the guidelines because our philosophy as practitioners is not to philosophize too much. We believe in learning by example, so we’ve provided plenty of those.

This guide doesn’t cover full-screen terminal programs like emacs and vim. Full-screen programs are niche projects—very few of us will ever be in the position to design one.

This guide is also agnostic about programming languages and tooling in general.

Who is this guide for?

  • If you are creating a CLI program and you are looking for principles and concrete best practices for its UI design, this guide is for you.
  • If you are a professional “CLI UI designer,” that’s amazing—we’d love to learn from you.
  • If you’d like to avoid obvious missteps of the variety that go against 40 years of CLI design conventions, this guide is for you.
  • If you want to delight people with your program’s good design and helpful help, this guide is definitely for you.
  • If you are creating a GUI program, this guide is not for you—though you may learn some GUI anti-patterns if you decide to read it anyway.
  • If you are designing an immersive, full-screen CLI port of Minecraft, this guide isn’t for you. (But we can’t wait to see it!)

Philosophy

These are what we consider to be the fundamental principles of good CLI design.

Human-first design

Traditionally, UNIX commands were written under the assumption they were going to be used primarily by other programs. They had more in common with functions in a programming language than with graphical applications.

Today, even though many CLI programs are used primarily (or even exclusively) by humans, a lot of their interaction design still carries the baggage of the past. It’s time to shed some of this baggage: if a command is going to be used primarily by humans, it should be designed for humans first.

Simple parts that work together

A core tenet of the original UNIX philosophy is the idea that small, simple programs with clean interfaces can be combined to build larger systems. Rather than stuff more and more features into those programs, you make programs that are modular enough to be recombined as needed.

In the old days, pipes and shell scripts played a crucial role in the process of composing programs together. Their role might have diminished with the rise of general-purpose interpreted languages, but they certainly haven’t gone away. What’s more, large-scale automation—in the form of CI/CD, orchestration and configuration management—has flourished. Making programs composable is just as important as ever.

Fortunately, the long-established conventions of the UNIX environment, designed for this exact purpose, still help us today. Standard in/out/err, signals, exit codes and other mechanisms ensure that different programs click together nicely. Plain, line-based text is easy to pipe between commands. JSON, a much more recent invention, affords us more structure when we need it, and lets us more easily integrate command-line tools with the web.

Whatever software you’re building, you can be absolutely certain that people will use it in ways you didn’t anticipate. Your software will become a part in a larger system—your only choice is over whether it will be a well-behaved part.

Most importantly, designing for composability does not need to be at odds with designing for humans first. Much of the advice in this document is about how to achieve both.

Consistency across programs

The terminal’s conventions are hardwired into our fingers. We had to pay an upfront cost by learning about command line syntax, flags, environment variables and so on, but it pays off in long-term efficiency… as long as programs are consistent.

Where possible, a CLI should follow patterns that already exist. That’s what makes CLIs intuitive and guessable; that’s what makes users efficient.

That being said, sometimes consistency conflicts with ease of use. For example, many long-established UNIX commands don’t output much information by default, which can cause confusion or worry for people less familiar with the command line.

When following convention would compromise a program’s usability, it might be time to break with it—but such a decision should be made with care.

Saying (just) enough

The terminal is a world of pure information. You could make an argument that information is the interface—and that, just like with any interface, there’s often too much or too little of it.

A command is saying too little when it hangs for several minutes and the user starts to wonder if it’s broken. A command is saying too much when it dumps pages and pages of debugging output, drowning what’s truly important in an ocean of loose detritus. The end result is the same: a lack of clarity, leaving the user confused and irritated.

It can be very difficult to get this balance right, but it’s absolutely crucial if software is to empower and serve its users.

Ease of discovery

When it comes to making functionality discoverable, GUIs have the upper hand. Everything you can do is laid out in front of you on the screen, so you can find what you need without having to learn anything, and perhaps even discover things you didn’t know were possible.

It is assumed that command-line interfaces are the opposite of this—that you have to remember how to do everything. The original Macintosh Human Interface Guidelines, published in 1987, recommend “See-and-point (instead of remember-and-type),” as if you could only choose one or the other.

These things needn’t be mutually exclusive. The efficiency of using the command-line comes from remembering commands, but there’s no reason the commands can’t help you learn and remember.

Discoverable CLIs have comprehensive help texts, provide lots of examples, suggest what command to run next, suggest what to do when there is an error. There are lots of ideas that can be stolen from GUIs to make CLIs easier to learn and use, even for power users.

Citation: The Design of Everyday Things (Don Norman), Macintosh Human Interface Guidelines

Conversation as the norm

GUI design, particularly in its early days, made heavy use of metaphor: desktops, files, folders, recycle bins. It made a lot of sense, because computers were still trying to bootstrap themselves into legitimacy. The ease of implementation of metaphors was one of the huge advantages GUIs wielded over CLIs. Ironically, though, the CLI has embodied an accidental metaphor all along: it’s a conversation.

Beyond the most utterly simple commands, running a program usually involves more than one invocation. Usually, this is because it’s hard to get it right the first time: the user types a command, gets an error, changes the command, gets a different error, and so on, until it works. This mode of learning through repeated failure is like a conversation the user is having with the program.

Trial-and-error isn’t the only type of conversational interaction, though. There are others:

  • Running one command to set up a tool and then learning what commands to run to actually start using it.
  • Running several commands to set up an operation, and then a final command to run it (e.g. multiple git adds, followed by a git commit).
  • Exploring a system—for example, doing a lot of cd and ls to get a sense of a directory structure, or git log and git show to explore the history of a file.
  • Doing a dry-run of a complex operation before running it for real.

Acknowledging the conversational nature of command-line interaction means you can bring relevant techniques to bear on its design. You can suggest possible corrections when user input is invalid, you can make the intermediate state clear when the user is going through a multi-step process, you can confirm for them that everything looks good before they do something scary.

The user is conversing with your software, whether you intended it or not. At worst, it’s a hostile conversation which makes them feel stupid and resentful. At best, it’s a pleasant exchange that speeds them on their way with newfound knowledge and a feeling of achievement.

Further reading: The Anti-Mac User Interface (Don Gentner and Jakob Nielsen)

Robustness

Robustness is both an objective and a subjective property. Software should be robust, of course: unexpected input should be handled gracefully, operations should be idempotent where possible, and so on. But it should also feel robust.

You want your software to feel like it isn’t going to fall apart. You want it to feel immediate and responsive, as if it were a big mechanical machine, not a flimsy plastic “soft switch.”

Subjective robustness requires attention to detail and thinking hard about what can go wrong. It’s lots of little things: keeping the user informed about what’s happening, explaining what common errors mean, not printing scary-looking stack traces.

As a general rule, robustness can also come from keeping it simple. Lots of special cases and complex code tend to make a program fragile.

Empathy

Command-line tools are a programmer’s creative toolkit, so they should be enjoyable to use. This doesn’t mean turning them into a video game, or using lots of emoji (though there’s nothing inherently wrong with emoji 😉). It means giving the user the feeling that you are on their side, that you want them to succeed, that you have thought carefully about their problems and how to solve them.

There’s no list of actions you can take that will ensure they feel this way, although we hope that following our advice will take you some of the way there. Delighting the user means exceeding their expectations at every turn, and that starts with empathy.

Chaos

The world of the terminal is a mess. Inconsistencies are everywhere, slowing us down and making us second-guess ourselves.

Yet it’s undeniable that this chaos has been a source of power. The terminal, like the UNIX-descended computing environment in general, places very few constraints on what you can build. In that space, all manner of invention has bloomed.

It’s ironic that this document implores you to follow existing patterns, right alongside advice that contradicts decades of command-line tradition. We’re just as guilty of breaking the rules as anyone.

The time might come when you, too, have to break the rules. Do so with intention and clarity of purpose.

“Abandon a standard when it is demonstrably harmful to productivity or user satisfaction.” — Jef Raskin, The Humane Interface

Guidelines

This is a collection of specific things you can do to make your command-line program better.

The first section contains the essential things you need to follow. Get these wrong, and your program will be either hard to use or a bad CLI citizen.

The rest are nice-to-haves. If you have the time and energy to add these things, your program will be a lot better than the average program.

The idea is that, if you don’t want to think too hard about the design of your program, you don’t have to: just follow these rules and your program will probably be good. On the other hand, if you’ve thought about it and determined that a rule is wrong for your program, that’s fine. (There’s no central authority that will reject your program for not following arbitrary rules.)

Also—these rules aren’t written in stone. If you disagree with a general rule for good reason, we hope you’ll propose a change.

The Basics

There are a few basic rules you need to follow. Get these wrong, and your program will be either very hard to use, or flat-out broken.

Use a command-line argument parsing library where you can. Either your language’s built-in one, or a good third-party one. They will normally handle arguments, flag parsing, help text, and even spelling suggestions in a sensible way.

Here are some that we like:

Return zero exit code on success, non-zero on failure. Exit codes are how scripts determine whether a program succeeded or failed, so you should report this correctly. Map the non-zero exit codes to the most important failure modes.

Send output to stdout. The primary output for your command should go to stdout. Anything that is machine readable should also go to stdout—this is where piping sends things by default.

Send messaging to stderr. Log messages, errors, and so on should all be sent to stderr. This means that when commands are piped together, these messages are displayed to the user and not fed into the next command.

Help

Display extensive help text when asked. Display help when passed -h or --help flags. This also applies to subcommands which might have their own help text.

Display concise help text by default. When myapp or myapp subcommand requires arguments to function, and is run with no arguments, display concise help text.

You can ignore this guideline if your program is interactive by default (e.g. npm init).

The concise help text should only include:

  • A description of what your program does.
  • One or two example invocations.
  • Descriptions of flags, unless there are lots of them.
  • An instruction to pass the --help flag for more information.

Show full help when -h and --help are passed. All of these should show help:

$ myapp
$ myapp --help
$ myapp -h

Ignore any other flags and arguments that are passed—you should be able to add -h to the end of anything and it should show help. Don’t overload -h.

If your program is git-like, the following should also offer help:

$ myapp help
$ myapp help subcommand
$ myapp subcommand --help
$ myapp subcommand -h

Provide a support path for feedback and issues. A website or GitHub link in the top-level help text is common.

In help text, link to the web version of the documentation. If you have a specific page or anchor for a subcommand, link directly to that. This is particularly useful if there is more detailed documentation on the web, or further reading that might explain the behavior of something.

Lead with examples. Users tend to use examples over other forms of documentation, so show them first in the help page, particularly the common complex uses. If it helps explain what it’s doing and it isn’t too long, show the actual output too.

You can tell a story with a series of examples, building your way toward complex uses.

If you’ve got loads of examples, put them somewhere else, in a cheat sheet command or a web page. It’s useful to have exhaustive, advanced examples, but you don’t want to make your help text really long.

For more complex use cases, e.g. when integrating with another tool, it might be appropriate to write a fully-fledged tutorial.

Display the most common flags and commands at the start of the help text. It’s fine to have lots of flags, but if you’ve got some really common ones, display them first.

Use formatting in your help text. Bold headings make it much easier to scan. But, try to do it in a terminal-independent way so that your users aren’t staring down a wall of escape characters.

If the user did something wrong and you can guess what they meant, suggest it. For example, brew update jq tells you that you should run brew upgrade jq.

You can ask if they want to run the suggested command, but don’t force it on them.

If your command is expecting to have something piped to it and stdin is an interactive terminal, display help immediately and quit. This means it doesn’t just hang, like cat. Alternatively, you could print a log message to stderr.

Documentation

The purpose of help text is to give a brief, immediate sense of what your tool is, what options are available, and how to perform the most common tasks. Documentation, on the other hand, is where you go into full detail. It’s where people go to understand what your tool is for, what it isn’t for, how it works and how to do everything they might need to do.

Provide web-based documentation. People need to be able to search online for your tool’s documentation, and to link other people to specific parts. The web is the most inclusive documentation format available.

Provide terminal-based documentation. Documentation in the terminal has several nice properties: it’s fast to access, it stays in sync with the specific installed version of the tool, and it works without an internet connection.

Consider providing man pages. man pages, Unix’s original system of documentation, are still in use today, and many users will reflexively check man mycmd as a first step when trying to learn about your tool.

Output

Human-readable output is paramount. Humans come first, machines second. The most simple and straightforward heuristic for whether a particular output stream (stdout or stderr) is being read by a human is whether or not it’s a TTY.

Have machine-readable output where it does not impact usability. Streams of text is the universal interface in UNIX. Programs typically output lines of text, and programs typically expect lines of text as input, therefore you can compose multiple programs together.

“Expect the output of every program to become the input to another, as yet unknown, program.” — Doug McIlroy

If human-readable output breaks machine-readable output, use --plain to display output in plain, tabular text format for integration with tools like grep or awk.

Display output as formatted JSON if --json is passed. JSON allows for more structure than plain text, so it makes it much easier to output and handle complex data structures.

Display output on success, but keep it brief. Traditionally, when nothing is wrong, UNIX commands display no output to the user. This makes sense when they’re being used in scripts, but can make commands appear to be hanging or broken when used by humans.

If you change state, tell the user. When a command changes the state of a system, it’s especially valuable to explain what has just happened, so the user can model the state of the system in their head—particularly if the result doesn’t directly map to what the user requested.

Make it easy to see the current state of the system. If your program does a lot of complex state changes and it is not immediately visible in the filesystem, make sure you make this easy to view.

Suggest commands the user should run. When several commands form a workflow, suggesting to the user commands they can run next helps them learn how to use your program and discover new functionality.

Actions crossing the boundary of the program’s internal world should usually be explicit.

Increase information density—with ASCII art! For example, ls shows permissions in a scannable way.

Use color with intention. For example, you might want to highlight some text so the user notices it, or use red to indicate an error. Don’t overuse it—if everything is a different color, then the color means nothing and only makes it harder to read.

Disable color if your program is not in a terminal or the user requested it.

If stdout is not an interactive terminal, don’t display any animations. This will stop progress bars turning into Christmas trees in CI log output.

Use symbols and emoji where it makes things clearer. Pictures can be better than words if you need to make several things distinct, catch the user’s attention, or just add a bit of character.

By default, don’t output information that’s only understandable by the creators of the software. If a piece of output serves only to help you understand what your software is doing, it almost certainly shouldn’t be displayed to normal users by default—only in verbose mode.

Don’t treat stderr like a log file, at least not by default. Don’t print log level labels (ERR, WARN, etc.) or extraneous contextual information, unless in verbose mode.

Use a pager (e.g. less) if you are outputting a lot of text. For example, git diff does this by default.

Errors

One of the most common reasons to consult documentation is to fix errors. If you can make errors into documentation, then this will save the user loads of time.

Catch errors and rewrite them for humans. If you’re expecting an error to happen, catch it and rewrite the error message to be useful.

Signal-to-noise ratio is crucial. The more irrelevant output you produce, the longer it’s going to take the user to figure out what they did wrong.

Consider where the user will look first. Put the most important information at the end of the output.

If there is an unexpected or unexplainable error, provide debug and traceback information, and instructions on how to submit a bug.

Make it effortless to submit bug reports. One nice thing you can do is provide a URL and have it pre-populate as much information as possible.

Arguments and flags

A note on terminology:

  • Arguments, or args, are positional parameters to a command.
  • Flags are named parameters, denoted with either a hyphen and a single-letter name (-r) or a double hyphen and a multiple-letter name (--recursive).

Prefer flags to args. It’s a bit more typing, but it makes it much clearer what is going on.

Have full-length versions of all flags. For example, have both -h and --help.

Only use one-letter flags for commonly used flags, particularly at the top-level when using subcommands.

Multiple arguments are fine for simple actions against multiple files. For example, rm file1.txt file2.txt file3.txt.

If you’ve got two or more arguments for different things, you’re probably doing something wrong. The exception is a common, primary action, where the brevity is worth memorizing.

Use standard names for flags, if there is a standard.

Make the default the right thing for most users. Making things configurable is good, but most users are not going to find the right flag and remember to use it all the time.

Prompt for user input. If a user doesn’t pass an argument or flag, prompt for it.

Never require a prompt. Always provide a way of passing input with flags or arguments.

Confirm before doing anything dangerous. A common convention is to prompt for the user to type y or yes if running interactively, or requiring them to pass -f or --force otherwise.

If input or output is a file, support - to read from stdin or write to stdout.

If a flag can accept an optional value, allow a special word like “none”.

If possible, make arguments, flags and subcommands order-independent.

Do not read secrets directly from flags. When a command accepts a secret, e.g. via a --password flag, the flag value will leak the secret into ps output and potentially shell history.

Interactivity

Only use prompts or interactive elements if stdin is an interactive terminal (a TTY).

If --no-input is passed, don’t prompt or do anything interactive.

If you’re prompting for a password, don’t print it as the user types.

Let the user escape. Make it clear how to get out.

Subcommands

If you’ve got a tool that’s sufficiently complex, you can reduce its complexity by making a set of subcommands. If you have several tools that are very closely related, you can make them easier to use and discover by combining them into a single command.

Be consistent across subcommands. Use the same flag names for the same things, have similar output formatting, etc.

Use consistent names for multiple levels of subcommand.

Don’t have ambiguous or similarly-named commands.

Robustness

Validate user input. Everywhere your program accepts data from the user, it will eventually be given bad data.

Responsive is more important than fast. Print something to the user in <100ms.

Show progress if something takes a long time. If your program displays no output for a while, it will look broken.

Do stuff in parallel where you can, but be thoughtful about it. It’s already difficult to report progress in the shell; doing it for parallel processes is ten times harder.

Make things time out. Allow network timeouts to be configured, and have a reasonable default so it doesn’t hang forever.

Make it recoverable. If the program fails for some transient reason, you should be able to hit <up> and <enter> and it should pick up from where it left off.

Make it crash-only. This is the next step up from idempotence.

People are going to misuse your program. Be prepared for that.

Future-proofing

In software of any kind, it’s crucial that interfaces don’t change without a lengthy and well-documented deprecation process.

Keep changes additive where you can.

Warn before you make a non-additive change.

Changing output for humans is usually OK.

Don’t have a catch-all subcommand.

Don’t allow arbitrary abbreviations of subcommands.

Don’t create a “time bomb.”

Signals and control characters

If a user hits Ctrl-C (the INT signal), exit as soon as possible. Say something immediately, before you start clean-up.

If a user hits Ctrl-C during clean-up operations that might take a long time, skip them.

Configuration

Command-line tools have lots of different types of configuration, and lots of different ways to supply it. The best way to supply each piece of configuration depends on a few factors, chief among them specificity, stability and complexity.

Configuration generally falls into a few categories:

  1. Likely to vary from one invocation of the command to the next.
  2. Generally stable from one invocation to the next, but not always.
  3. Stable within a project, for all users.

Follow the XDG-spec.

If you automatically modify configuration that is not your program’s, ask the user for consent and tell them exactly what you’re doing.

Apply configuration parameters in order of precedence. Here is the precedence for config parameters, from highest to lowest:

  • Flags
  • The running shell’s environment variables
  • Project-level configuration (e.g. .env)
  • User-level configuration
  • System wide configuration

Environment variables

Environment variables are for behavior that varies with the context in which a command is run.

For maximum portability, environment variable names must only contain uppercase letters, numbers, and underscores (and mustn’t start with a number).

Aim for single-line environment variable values.

Avoid commandeering widely used names.

Check general-purpose environment variables for configuration values when possible:

  • NO_COLOR, to disable color or FORCE_COLOR to enable it and ignore the detection logic
  • DEBUG, to enable more verbose output
  • EDITOR, if you need to prompt the user to edit a file or input more than a single line
  • HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY, if you’re going to perform network operations
  • SHELL, if you need to open up an interactive session of the user’s preferred shell
  • TERM, TERMINFO and TERMCAP, if you’re going to use terminal-specific escape sequences
  • TMPDIR, if you’re going to create temporary files
  • HOME, for locating configuration files
  • PAGER, if you want to automatically page output
  • LINES and COLUMNS, for output that’s dependent on screen size

Read environment variables from .env where appropriate.

Don’t use .env as a substitute for a proper configuration file.

Do not read secrets from environment variables.

Naming

“Note the obsessive use of abbreviations and avoidance of capital letters; [Unix] is a system invented by people to whom repetitive stress disorder is what black lung is to miners. Long names get worn down to three-letter nubbins, like stones smoothed by a river.” — Neal Stephenson, In the Beginning was the Command Line

The name of your program is particularly important on the CLI: your users will be typing it all the time, and it needs to be easy to remember and type.

Make it a simple, memorable word. But not too generic, or you’ll step on the toes of other commands and confuse users.

Use only lowercase letters, and dashes if you really need to. curl is a good name, DownloadURL is not.

Keep it short. Users will be typing it all the time.

Make it easy to type. If you expect people to type your command name all day, make it easy on their hands.

Distribution

If possible, distribute as a single binary.

Make it easy to uninstall. If it needs instructions, put them at the bottom of the install instructions—one of the most common times people want to uninstall software is right after installing it.

Analytics

Usage metrics can be helpful to understand how users are using your program, how to make it better, and where to focus effort. But, unlike websites, users of the command-line expect to be in control of their environment, and it is surprising when programs do things in the background without telling them.

Do not phone home usage or crash data without consent. Users will find out, and they will be angry.

Consider alternatives to collecting analytics.

  • Instrument your web docs.
  • Instrument your downloads.
  • Talk to your users.

Further reading

The Daily Front Page 13 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — The Button and the Universe
article

If you want to create a button from scratch, you must first create the universe

by treve·▲ 242 points·128 comments·madcampos.dev ↗
The first rule of ARIA is to not use ARIA.

A still from the "Cosmos" TV series, with Carl Sagan seated at a table with an apple pie in front of him. Overlayed on the apple pie is a button reading: "W3C WAI-AAA WCAG 2.2".

A concrete example of why using native HTML as much as possible is better than slapping ARIA on top of things.

The title is a tongue-in-cheek parody of a quote from Carl Sagan(External link).
And it ties in very well with the thought exercise we are doing today.

When we start learning about web accessibility the first thing we hear one of two things:

The first rule of ARIA is to not use ARIA.

And:

If there is a native element, use that instead of recreating the element.

But people never explain why this advice is given, and why we should not recreate things from scratch.

I’m going to try to explain why recreating some components from scratch is usually a Sisyphean task(External link) with the example of (mostly) recreating a button from scratch.

Newtonian laws of UX

Native elements have a set of expectations on their behaviour. They follow some laws of UX. (See what I did there? lol)

Users on your application expect to interact with the element in specific ways and expect that the element will behave in specific ways.

For references, we will use the following links as a base of what a button is, how it behave, and how users expect to interact with it.

Those references give us the following list or requirements.
Along with the items are the WCAG Success Criteria (SC) that represent that requirement.

  1. Have a role of button. (SC 4.1.2(External link))
  2. Have an accessible label. (SC 4.1.2(External link), and SC 1.3.1(External link))
  3. Be focusable. (SC 2.4.3(External link), and SC 2.4.7(External link))
  4. Activate with a mouse click.
  5. Activate by touch.
  6. Activate by stylus and other pointing devices.
  7. Activate on Space and Enter keys when focused. (SC 2.1.1(External link))
  8. Have a type and: submit a form; reset a form; or not do anything with the related form.
  9. A name and value, and form attributes when participating in forms. (SC 4.1.2(External link))
  10. Participate in form validation. (SC 3.3.1(External link))
  11. Support states, like disabled. (SC 4.1.2(External link))
  12. Support newer APIs like Popover API(External link), Invoker Commands API(External link), or Interest Invokers API(External link).

If that didn’t scare you already of trying to re-implement a button from scratch, buckle up, because we will work through that list and I hope that will discourage you from recreating elements.

Custom element nuclei

Our custom element nucleus will be a new tag. We do so because custom tags(External link) behave just like a <span> by default. That gives us an “empty state” to start working on.

We then add a role of button to it. Now it will be exposed to the accessibility tree as a button.

NOTE: styling the element will be left as an exercise to the reader for two reasons:

  1. It will make things unnecessarily longer for this blog post.
  2. Custom elements can be styled however you like, with no general restrictions.
<sagan-button role="button">A button from scratch</sagan-button>

So far so good…

Periodic table of ARIA

Our button should also have an accessible label, that can be either provided by the developer or as a last case fallback derived from the element’s content.

The problem here is specifically icon buttons. If we update our markup to something like this:

<sagan-button role="button">💩</sagan-button>

It will be read by a screen reader as something like1:

Button, pile of poo

There are a few issues here:

To remediate this we could add a label to the button, and mark the emoji as presentational and/or hide it, so it is not announced by screen readers.

The code will look as follows:

<sagan-button role="button" aria-label="Data Sharing Options">
	<span role="presentation" aria-hidden="true">💩</span>
</sagan-button>

The focusable universe

The component now looks like a button, is announced like a button, but cannot be interacted with yet.

The first part is to make the element keyboard focusable, to do so, we need to add the tabindex attribute.
This will make the element become part of the tab order of the page(External link), meaning when a user press Tab they will be able to navigate and reach the element.

<sagan-button
	role="button"
	aria-label="Data Sharing Options"
	tabindex="0"
>
	<span role="presentation" aria-hidden="true">💩</span>
</sagan-button>

The value for the attribute here is 0 so the element will follow the existing order of the page, any positive value would make it jump in front of other elements and the focus of the page move all over, so it is not recommended(External link).

Fundamental element interactions

We then need to add some way to activate the component. Click, touch, and pointer interactions are ways to interact with the element using those modalities.

They cover, click for mouse buttons, touch for touch screens, and pointer for everything else like stylus.

NOTE: For maintaining compatibility, browsers do map both touch and pointer events (to some extent) to click events but here we are implementing all of them independently for the sake of argument.

Some keyboard events may also be mapped by default, but it gets more complicated than that...

We then add the events. Starting with the mouseup for handling mouse events:

<sagan-button
	role="button"
	aria-label="Data Sharing Options"
	tabindex="0"
	onomouseup="console.log('mouse')"
>
	<span role="presentation" aria-hidden="true">💩</span>
</sagan-button>

Let’s also add two more events:

  • ontouchend for handling when the user lifts their finger from the element.
  • onpointerup for handling other pointer devices, like styluses.

Now our component looks like this:

<sagan-button
	role="button"
	aria-label="Data Sharing Options"
	tabindex="0"
	onmouseup="console.log('mouse')"
	ontouchend="console.log('touch')"
	onpointerup="console.log('pointer')"
>
	<span role="presentation" aria-hidden="true">💩</span>
</sagan-button>

JS galaxy formation

The code for the custom element is getting a little bit verbose on the HTML side of things.
We will give it a little bit more powers by using JavaScript, registering the component(External link), and attaching a Shadow DOM(External link).

NOTE: To make event handling easier, it is done using the handleEvent method of our component.

This article explains the event handling pattern in detail.

The code for the element looks like this:

class SaganButton extends HTMLElement {
	// A reference to the element internals,
	// this allows us to do some thigns with the element we cannot do otherwise.
	/** @type {ElementInternals} */
	#internals;

	constructor() {
		super();

		this.attachShadow({ mode: 'open' });
		this.#internals = this.attachInternals();

		// Add back the things we had before.
		this.#internals.role = 'button';
		this.#internals.ariaLabel = 'Data Sharing Options';
		this.tabindex = 0;
	}

	/**
	 * @param {Event} evt
	 */
	handleEvent(evt) {
		// We here filter by event type
		switch (evt.type) {
			// For all of those we want to do some action.
			// So here we use a fallthrough here.
			case 'mouseup':
			case 'touchend':
			case 'pointerup':
				this.#doButtonAction(evt);
				break;
		}
	}

	connectedCallback() {
		this.shadowRoot.innerHTML = `
			<slot></slot>
		`;

		// We add the event listeners
		// when the element is added to the DOM.
		this.addEventListener('mouseup', this);
		this.addEventListener('touchend', this);
		this.addEventListener('pointerup', this);
	}

	disconnectedCallback() {
		// And remove the listeners
		// when the element is removed from the DOM.
		this.removeEventListener('mouseup', this);
		this.removeEventListener('touchend', this);
		this.removeEventListener('pointerup', this);
	}

	/**
	 * @param {Event} evt
	 */
	#doButtonAction(evt) {
		// TODO: Implement the actual button action.
	}
}

Okay, we are back to where we were before adding JS into the mix, but now with more complexity! 🎉

Cosmic Keyboard Interaction (CKI)

The button can be activated with clicks, touches, and pointers, but not using the keyboard. We now need to add a keyup and keydown event listener so the component can be interacted with using the keyboard.

NOTE: this is the bare minimum for a semi-working keyboard accessible button.

There are more nuance on the events and expectations. Adrian Roselli covers this topic in more details.

class SaganButton extends HTMLElement {
	connectedCallback() {
		// ...

		this.addEventListener('keyup', this);
		this.removeEventListener('keydown', this);
	}

	disconnectedCallback() {
		// ...

		this.removeEventListener('keyup', this);
		this.removeEventListener('keydown', this);
	}

	/**
	 * @param {Event} evt
	 */
	handleEvent(evt) {
		switch (evt.type) {
			// ...

			case 'keyup':
				this.#handleSpaceActivation(evt);
				break;
			case 'keydown':
				this.#handleEnterActivation(evt);
				break;
		}
	}

	/**
	 * @param {KeyboardEvent} evt
	 */
	#handleSpaceActivation(evt) {
		// Don't do anything if the space bar is not pressed.
		if (evt.key !== ' ') {
			return;
		}

		if (document.activeElement !== this) {
			return;
		}

		this.#doButtonAction(evt);
	}

	/**
	 * @param {KeyboardEvent} evt
	 */
	#handleEnterActivation(evt) {
		if (evt.key !== 'Enter') {
			return;
		}

		this.#doButtonAction(evt);
	}
}

Absolute zero: When elements become disabled

Now we are getting into attribute territory, this means implementing behaviors to match the native <button> element.

From the MDN docs on the <button> element(External link), one of the attributes is the disabled attribute. Let’s use it as a starting point for the pattern to handle attributes we will use.

The attribute pattern is as follows:

  1. Add the attribute to the observedAttributes array.
  2. Create a getter and setter pair for the corresponding property.
  3. On the setter, reflect the normalized new value on the attribute.
  4. On the getter, return the normalized attribute value.
  5. On attribute changes, update the properties.

The pattern allows for both attributes and properties to be kept in sync, as well as validating and normalizing value as needed, as well as falling back to default values when needed.

It is the beginning of a reactive system, and can be used as the basis to implement dynamic updates to the DOM. But I digress…

For the disabled attribute, we first need to set the ariaDisabled property of the elementInternals. Then add a new state to the element so it can be styled by CSS.

class SaganButton extends HTMLElement {
	// Specify the attributes to observe.
	static observedAttributes = [
		'disabled'
	];

	// Add a setter...
	/**
	 * @param {boolean} newValue
	 */
	set disabled(newValue) {
		this.toggleAttribute('disabled', newValue);

		// Note: `aria-disabled` is a string of "true" or "false", not a boolean.
		this.#internals.ariaDisabled = newValue ? 'true' : 'false';

		if (newValue) {
			this.#internals.states.add('--disabled');
		} else {
			this.#internals.states.delete('--disabled');
		}
	}

	// Then a getter...
	get disabled() {
		return this.hasAttribute('disabled');
	}

	// Watch for changes on the attribute.
	/**
	 * @param {string} name
	 * @param {string | null} oldValue
	 * @param {string | null} newValue
	 */
	attributeChangedCallback(name, oldValue, newValue) {
		if (oldValue === newValue) {
			return;
		}

		switch (name) {
			case 'disabled':
				this.disabled = newValue !== null;
				break;
		}
	}

	// ...
}

To add styles to the disabled state the CSS is similar to the one below:

sagan-button:state(--disabled) { /* Styles for disabled state */ }

Element interactions

Buttons started out as elements related to forms, so there are a lot of attributes for interacting with, you guested, forms!

Again from MDN docs on the <button> element, here are the attributes related to forms:

  • type: The button type, it can be one of 3 options (submit, reset, button). The options trigger the associated form submission, form reset, or don’t do anything.
  • form: the associated form, this is useful for the case where the button is not in the sub tree of the form element.
  • formaction: a URL to submit the form to, this is so you can have different buttons submitting the form to different URLs.
  • formmethod: Same as the above, different buttons can send different methods. HTML baby!
  • formenctype: This also allows for different ways of sending the form data over the wire2.
  • formnovalidate: Says the form should skip validation when this button is used.
  • formtarget: If the form should open a new tab on submission or not.
  • name and value: if present those are added to the form on submission.

Aside from the attributes, there are also some properties related to the Validation API(External link):

  • validity: The validity state of the button, it only makes sense for inputs, for buttons it will usually be the same.
  • validationMessage: The error message to show to the user if there is an error for this button.
  • willValidate: If this button will be a part of the constraint validation for the form or not.
  • checkValidity/reportValidty: Methods to validate the element.
  • setCustomValidity: Sets a custom error message to this element.

Ah, yes, we also need to set the formAssociated static property to the element so everything works, without it, we would have a lot of errors.

Well… Let’s get to writing all that…

class SaganButton extends HTMLElement {
	// To make it interact with forms...
	static formAssociated = true;

	static observedAttributes = [
		'type',
		'form',
		'formaction',
		'formmethod',
		'formenctype',
		'formnovalidate',
		'formtarget',
		'name',
		'value'
		// ...
	];

	/**
	 * @param {string | null} newValue
	 */
	set type(newValue) {
		// The default for button types is "submit",
		// So we validate and default to it if the value does not match.
		if (newValue && ['submit', 'reset', 'button'].includes(newValue?.toLowerCase())) {
			this.setAttribute('type', newValue);
		} else {
			this.setAttribute('type', 'submit');
		}
	}

	get type() {
		return this.getAttribute('type') ?? 'submit';
	}

	/**
	 * @param {string | null} newValue
	 */
	set form(newValue) {
		if (newValue === null) {
			this.removeAttribute('form');
			return;
		}

		// Here we need to check if the form is a valid element.
		// If it is, we then set the attribute.
		const form = document.getElementById(newValue);
		if (form) {
			this.setAttribute('form', newValue);
		}
	}

	/**
	 * @returns {HTMLFormElement | null}
	 */
	get form() {
		// Here we get the value from the attribute as the first option.
		const formId = this.getAttribute('form');
		if (formId) {
			return document.getElementById(formId);
		}

		// Then fallback to whatever the browser has set.
		return this.#internals.form;
	}

	/**
	 * @param {string | null} newValue
	 */
	set formAction(newValue) {
		if (newValue === null) {
			this.removeAttribute('formaction');
			return;
		}

		this.setAttribute('formaction', newValue);
	}

	get formAction() {
		return this.getAttribute('formaction');
	}

	/**
	 * @param {string | null} newValue
	 */
	set formMethod(newValue) {
		// The default method is "get"
		if (newValue && ['post', 'get', 'dialog'].includes(newValue.toLowerCase())) {
			this.setAttribute('formmethod', newValue);
		} else {
			this.setAttribute('formmethod', 'get');
		}
	}

	get formMethod() {
		return this.getAttribute('formmethod') ?? 'get';
	}

	/**
	 * @param {string | null} newValue
	 */
	set formEnctype(newValue) {
		// The default value is "form url encoded".
		if (
			newValue &&
			[
				'application/x-www-form-urlencoded',
				'multipart/form-data',
				'text/plain'
			].includes(newValue.toLowerCase())
		) {
			this.setAttribute('formenctype', newValue);
		} else {
			this.setAttribute('formenctype', 'application/x-www-form-urlencoded');
		}
	}

	get formEnctype() {
		return this.getAttribute('formenctype') ?? 'application/x-www-form-urlencoded';
	}

	/**
	 * @param {boolean} newValue
	 */
	set formNoValidate(newValue) {
		this.toggleAttribute('formnovalidate', newValue);
	}

	get formNoValidate() {
		return this.hasAttribute('formnovalidate');
	}

	/**
	 * @param {string | null} newValue
	 */
	set formTarget(newValue) {
		// The default value is "_self".
		if (newValue && ['_self', '_blank', '_parent', '_top'].includes(newValue.toLowerCase())) {
			this.setAttribute('formtarget', newValue);
		} else {
			this.setAttribute('formtarget', '_self');
		}
	}

	get formTarget() {
		return this.getAttribute('formtarget') ?? '_self';
	}

	/**
	 * @param {string | null} newValue
	 */
	set name(newValue) {
		if (newValue === null) {
			this.removeAttribute('name');
			return;
		}

		this.setAttribute('name');
	}

	get name() {
		return this.getAttribute('name');
	}

	/**
	 * @param {string | null} newValue
	 */
	set value(newValue) {
		if (newValue === null) {
			this.removeAttribute('value');
			return;
		}

		this.setAttribute('value', newValue);

		// We need to set the button's value.
		this.#internals.setFormValue(newValue);
		// And the validation state.
		this.#internals.setValidity({});

		// And the valid/invalid states.
		this.#internals.states.add('--valid');
		this.#internals.states.delete('--invalid');
	}

	get value() {
		return this.getAttribute('value');
	}

	get willValidate() {
		if (this.type === 'button' || this.type === 'reset') {
			return false;
		}

		if (this.closest('datalist')) {
			return false;
		}

		if (this.disabled) {
			return false;
		}

		if (!this.#internals.validity.customError) {
			return false;
		}

		return this.#internals.willValidate;
	}

	get validationMessage() {
		return this.willValidate ? this.#internals.validationMessage : '';
	}

	get validity() {
		return this.#internals.validity;
	}

	checkValidity() {
		return this.#internals.checkValidity();
	}

	reportValidity() {
		return this.#internals.reportValidity();
	}

	/**
	 * @param {string} message
	 */
	setCustomValidity(message) {
		if (message) {
			this.#internals.setValidity({ customError: true }, message);
			this.#internals.states.add('--invalid');
			this.#internals.states.delete('--valid');
		} else {
			this.#internals.setValidity({});
			this.#internals.states.add('--valid');
			this.#internals.states.delete('--invalid');
		}
	}

	/**
	 * @param {string} name
	 * @param {string | null} oldValue
	 * @param {string | null} newValue
	 */
	attributeChangedCallback(name, oldValue, newValue) {
		// ...

		switch (name) {
			// ...

			case 'type':
				this.type = newValue;
				break;
			case 'form':
				this.form = newValue;
				break;
			case 'formaction':
				this.formAction = newValue;
				break;
			case 'formmethod':
				this.formMethod = newValue;
				break;
			case 'formenctype':
				this.formEnctype = newValue;
				break;
			case 'formnovalidate':
				this.formNoValidate = newValue !== null;
				break;
			case 'formtarget':
				this.formTarget = newValue;
				break;
			case 'name':
				this.name = newValue;
				break;
			case 'value':
				this.value = newValue;
				break;
		}
	}
}

For every action…

After a wall of text, we write the logic for what to actually do once the button is activated. We defined a #doButtonAction method before but didn’t implement it.

Now is the time to implement that method:

class SaganButton extends HTMLElement {
	// ...

	#doButtonAction() {
		// First check if the button is disabled,
		// On that case nothing should happen.
		if (this.disabled) {
			return;
		}

		// Then check the button type.
		if (this.type === 'button') {
			return;
		}

		// Here we reset the form if the button is a reset one.
		if (this.type === 'reset') {
			this.form?.reset();
			return;
		}

		// Finally, if it is a submit button,
		// AND IF we wired everything correctly,
		// The form will be handled by the browser.
		this.form?.requestSubmit(this);
	}

	// ...
}

We could also handle all the validations and form submission by ourselves, including the button and form validations. But this is already a painful and long enough post as it is.
So to avoid more self inflicted pain, in the end, we let the browser handle the form submission for us.

Event listener horizon

There are a couple of things that happen when the button is activated that are not covered:

  • The component should change to the active state.
  • If any listener calls preventDefault the default action that we just implemented should not run.

If we simply register event listeners to the button, as we have so far with addEventListener it would make our default event listener not work, as it may run out of order with other event listeners added by the user using our component.

We want to run an event listener before and another after every other listener. To do so we need to wrap the addEventListener, as well as all the on* attributes(External link) we care about.

Here is the code for wrapping the addEventListener method:

class SaganButton extends HTMLElement {
	/** @type {Element['addEventListener']} */
	#originalAddEventListener;

	constructor() {
		// ...

		// Add event listeners for to set the active state.
		this.addEventListener('pointerdown', this.#setActiveState);
		this.addEventListener('touchstart', this.#setActiveState);
		this.addEventListener('mousedown', this.#setActiveState);
		this.addEventListener('keydown', this.#setActiveState);

		// Keep a reference to the original method.
		this.#originalAddEventListener = this.addEventListener;

		// Wrap the original method.
		this.addEventListener = this.#wrappedAddEventListener;
	}

	/**
	 * @template {keyof ElementEventMap} K
	 * @param {K} type
	 * @param {EventListenerOrEventListenerObject} listener
	 * @param {boolean | EventListenerOptions} [options]
	 */
	#wrappedAddEventListener(type, listener, options) {
		const defaultEvents = ['click', 'pointerup', 'touchend', 'keyup', 'keydown'];

		// If one of the default events,
		// Remove the default event listener, and re-add it as the last one.
		if (defaultEvents.includes(type)) {
			this.removeEventListener(type, this);

			this.#originalAddEventListener.call(this, type, listener, options);
			this.addEventListener(type, this);
		} else {
			this.#originalAddEventListener.call(this, type, listener, options);
		}
	}

	#setActiveState() {
		if (this.disabled) {
			return;
		}

		this.#internals.states.add('--active');
	}

	/**
	 * @param {Event} evt
	 */
	#doButtonAction(evt) {
		// After the disabled check...

		this.#internals.states.delete('--active');

		if (evt.defaultPrevented) {
			return;
		}
		// ...
	}
}

For the attribute event listeners, there are two caveats to note:

  • The attributes accept a string.
  • The property accepts a function.

For the property case, it would be as simple as calling the #wrappedAddEventListener.

The problem is with the attribute, as it can accept a number of things that should be parsed as JavaScript code(External link).

Here is one example for the onmosueup attribute and property:

class SaganButton extends HTMLElement {
	static observedAttributes = [
		// ...
		'onmouseup'
	];

	// Keep a list of event listeners for attributes.
	/** @type {Record<string, EventListener>} */
	#attributeEventListeners = {};

	/**
	 * @param {EventListener | null} newValue
	 */
	set onmouseup(newValue) {
		if (!newValue) {
			// Check if there is already a saved listener.
			const savedListener = this.#attributeEventListeners['mouseup'];
			if (savedListener) {
				// Then remove it, and delete the reference.
				this.removeEventListener('mouseup', savedListener);
				delete this.#attributeEventListeners['mouseup'];
			}
		} else {
			// Add a new listener and save the reference.
			this.addEventListener('mouseup', newValue);
			this.#attributeEventListeners['mouseup'] = newValue;
		}
	}

	get onmouseup() {
		return this.#attributeEventListeners['mouseup'] ?? null;
	}

	/**
	 * @param {string} name
	 * @param {string | null} oldValue
	 * @param {string | null} newValue
	 */
	attributeChangedCallback(name, oldValue, newValue) {
		// ...

		switch (name) {
			// ...
			case 'onmouseup':
				this.onmouseup = this.#parseAttributeListener(newValue);
				break;
		}
	}

	/**
	 * @param {string | null} value
	 */
	#parseAttributeListener(value) {
		// If no value is passed it returns null.
		if (!value) {
			return null;
		}

		try {
			// NOTE: This is very bad practice!
			// It creates an immediatly invoked function body out of the value given.
			// When it is called, it will execute whatever is passed as the value.
			return new Function('evt', `(${value})(evt);`);
		} catch (err) {
			// If there is an error with the function, returns null.
			console.error(err);
			return null;
		}
	}
}

Unexplored API space

There are APIs like the Popover API(External link), or Invoker Commands API(External link), or even the experimental Interest Invoker API(External link). All those APIs bring more features to HTML in a way that is easy for developers to use and handles all the required logic by the browser.

I will skip those because they require extra code or relate to other elements around the button like dialogs. Also this post is just north of 4000 words, so yeah…

In closing

USE SEMANTIC HTML!

That’s is. Don’t reinvent the wheel if you don’t absolutely need to.
It is too much work, and a lot of extra burden for you to maintain.

The example code for this button component is almost 500 lines of JS (see it below), where a native HTML button is 0 lines of JS.
All that work to achieve only some of the functionality! That is an insane amount of work for almost no return, so do yourself a favour and just use the semantic native options.

Appendix A - Frequently Asked Questions (FAQ)

Question: But what if I only need a subset of features?

Even if you use only part of the functionality, using semantic HTML is less code to maintain and makes your codebase easier to reason about.
On the plus side, if you ever need some other functionality, it is already there, provided by the platform, no extra code.

Question: But what if the element doesn’t allow me to use my brand colours?

Styling elements would have been a big issue on ye olde IE days. Nowadays it is mostly okay.
This MDN guide on styling forms(External link) gives a good breakdown of what is easy and what is not. Even for the harder ones it requires more CSS massaging, but it is still technically feasible.
Also, have you heard about custom selects(External link)?

Another point is that hand rolling a component is a lot of work and most of the time breaks user expectations, talk to the designer and ask the question: is this the best user experience or could we use another pattern?

Lastly, inaccessible pages are liable to legal actions. Better keep those lawyers happy and the fines away, am I right?

Question: But what if the elements don’t play well with [insert framework of choice here]?

Well… Sometimes we can’t choose the tools we use, but we still can make the best out of them. For the code you write, use more semantic HTML and test things out.

Question: But my agent generates horrible inaccessible code, what should I do?

Unfortunately LLMs generate inaccessible code by default, but not all is lost, this article proposes solutions to the problem(External link).

Appendix B - The final code

All the glorious almost 500 lines of it!

Component code available here

Footnotes

  1. The exact reading by the screen reader is not the point here, the main problem is that emojis are read out loud by a name that is usually not intuitive. Back to reference 1
  2. In modern development, where everything is sent to the server as JSON, this is a forgotten piece of how basic HTML can do a lot! Back to reference 2
The Daily Front Page 14 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Queues, Delays, and Other Traps
article

Job queues are deceptively tricky

by ingve·▲ 138 points·48 comments·typesanitizer.com ↗
Reality has a surprising amount of detail.

One of the fun things about being a programmer is that as I look more into systems that I didn’t know much about, what superficially appears to be a simple system actually reveals interesting facets of underlying complexity. In other words, reality has a surprising amount of detail.

In this post, I want to talk about job queues, which I’ve been thinking about for the past few days (and while drafting this post in my head, I realized I’d thought about them for longer at a previous job, but not nearly with as much clarity.)

What do I mean by “job queue”? I mean a system where there is some notion of submitting batch jobs, scheduling them, and running them. Generally, the system is expected to be FIFO or FIFO-like, but that’s not required. Usually, the queue bundles together a native way to schedule jobs periodically – this lets you specify submissions using configuration files (e.g. using JSON or YAML).

Job queues arise naturally in all sorts of situations where throughput requirements are high, but latency requirements are not that high. Continuous integration is a common example. Another example is summaries for the purpose of data analysis.

Over the past year or two, a handful of lenses that I’ve found useful when thinking about system design are:

  • Being wary of queues: Queues tend to typically be close to full or close to empty, requiring some thought around appropriate sizing. I’ve also learnt a bunch of counter-intuitive behavior of queues when it comes to latency from Marc Brooker’s blog.In blog posts I’ve read about queues, the focus is generally on latency, not on throughput, which is I think partly why I never really mentally linked “queues” and “job queues” earlier.

  • Limits: This is based on the Tiger Style guide written by the folks at TigerBeetle, which talks about explicit limits on various things. On generalization, this leads to the notion of tolerances and having budgets for modular reasoning.Funnily enough, near the start of my career at Apple, I remember being mildly bemused at certain discussions where teams would discuss how much memory budget they would have to bargain for, especially for low-level code. I have grown more respect for that approach over time.

  • Fault models: Roughly speaking, a fault model describes your assumptions related to errors and reliability in your dependencies.

    I’ve learnt a bunch on this topic from the talks and tweets by the TigerBeetle CEO Joran Dirk Greef, as well as writing by Alex Miller.

I will try to demonstrate how these lenses can apply for system design later in this post.

The problem at hand

At $WORK, we have a background job for packing “reference repos”. A reference repo is a git repo which has been aggressively repacked – if you’re unfamiliar with repacking, you can think of this as more “aggressively compressed”.

These are stored in object storage, and a new repo can be set up on a machine by downloading the reference repo, and fetching a delta of changes for the tip of the default branch. When it comes to very large repos, for the downloader, this approach helps cut down latency compared to the more typical approach of doing a git clone operation.

The actual details of how git does aggressive repacking is not super relevant for this post. What does matter is that there are, roughly speaking, two forms of repacking available:

  • Wholesale repacking: This will lead to git ignoring any baseline information about how packing should be done, and recompute everything from scratch. Say for the repo under test, this takes 7 hours.If you’re surprised at the thought of a git operation taking several hours, (1) you should be happy that you don’t have this problem and (2) some of the sub-operations here are seemingly single-threaded, regardless of repo size.
  • Incremental repacking: This will lead to git reusing the baseline information already stored in the repo about packing, so it will only repack things which have changed. So if you did a clone, then did a fetch, then only the newer changes from fetch will be repacked, and only some cursory checks will be performed on the history received from the clone operation. Say this takes 2 hours.

OK. So that’s the setup.

We have the option of doing the more expensive thing, which takes 7 hours. This buys us a smaller reference repo, which means faster downloads, faster un-tarring and lower disk usage. To give a size ballpark, a wholesale repacked repo can be up around 50-60% smaller than an incrementally packed repo.

We have the option of doing the cheaper thing, which takes 2 hours. This buys us more up-to-date reference repos, which means that if the downloader still cares about getting the latest changes, the delta it will fetch subsequently will be smaller, so it’ll be faster and put less load on the server. But also, if the extra disk usage is in the hundreds of megabytes or gigabytes, then that’s not great.

One more thing to note is that since this is a code repository, downstream consumers are much more active on weekdays than on weekends.

The best of both worlds?

A natural next step upon seeing the above dichotomy is to propose the following: why not do the wholesale repacking on the weekends, and do the incremental repacking on the weekdays? That seems like it buys up-to-dateness on weekdays (where more consumers are active), and the wholesale repacking on the weekend would ensure that the reference repo size grows more slowly.

For simplicity, let’s say we’re writing the reference repos with some key <myrepo>-<timestamp>.tar into some bucket.

To keep the size of the incrementally packed repo smaller, one can either:

  • Write the wholesale repacked repos using the same key scheme, and have the incremental repacking jobs bootstrap from the last successful repack (wholesale or incremental), instead of from a clone operation.
  • Write the wholesale repacked repos using an altered key scheme, say <myrepo>-<timestamp>-packed.tar, and bootstrap the incremental repack from that key location. However, this potentially creates the need to update other consumers so that they don’t lag behind too much on weekends (assuming incremental repacking is not running on weekends).

Let’s say we go with the first one because it seems simpler.

So now the question is, how are we going to handle this scheduling?

Typical job queues only expose limited control of scheduling to clients for good reason – providing a large number of knobs increases the risk of surprising scheduling decisions.

When I wrote the “natural next step” above, I’m essentially thinking from the point of view of writing the control loop. But when I’m using a job queue, I do not (by definition) have access to the control loop – someone else has written that loop, and I need to see what configuration knobs are available to me to customize the behavior.

For now, let’s say the queue exposes two configuration knobs:

  • Scheduling interval: The job is started on a timer configured with this interval.
  • Concurrency limit: The maximum number of running jobs for the particular configuration.

Before we started this optimization journey, let’s say these configuration knobs were set as:

  • Scheduling interval: 9 hours, to leave some headroom from the 7 hour running time of the job.
  • Concurrency limit: 1, because there’s not much point in having concurrent jobs do the same thing here.

Again, a natural next step here might be to think: “So the 9 hour interval was sufficient for a 7 hour running time job. Since the incremental repack job on weekdays will take 2 hours, let’s set the interval to be 3 hours. On the weekends, when the wholesale repacking job is running, it will not have finished in 3 hours, so even if the timer triggers again, the concurrency limit will prevent any other jobs from running, so things should be fine.”

Unfortunately, dear reader, this simplistic reasoning does not work.

The set of possible semantics

For a moment, let’s stop thinking about trying to think about how we can use the job queue. Rather, let’s think about implementing a job queue.

Let’s say there’s a job which is running J1 based on some configuration J. After the scheduling interval elapses, say we want to create a new job J2, and J1 hasn’t completed yet. What should we do with J2? Essentially, there are only four options:

  • Parallel Spawn: If the concurrency limit is higher than 2, then we can just start running J2.

  • If the concurrency limit is 1, then we have three choices:

    • Prefer New: Stop J1 and start J2.
    • Wait: Wait for J1 to finish and then start J2.
    • Prefer Old: Auto-cancel J2 and let J1 continue running.

Pause for a moment here, and ask yourself what’s your intuitive answer to the following question: which of these possible semantics are worth implementing or could be called reasonable?

If you’re anything like me, you would probably have said Parallel Spawn, Prefer New, and Wait are perhaps defensible, whereas Prefer Old feels weird/backward. For Prefer New vs Prefer Old in particular, you may have justified this to yourself with reasoning like: “if this situation happens, the job owner probably cares about more recent results than older results so it makes sense to support Prefer New but it seems strange to support Prefer Old, who would ever want that?”


Recall the lenses from earlier: vigilance about queues, limits and fault models.

So the first thing to note is that the Wait option requires a (logical) per-job-config queue. The queue should probably be bounded (per Limits). OK, so how should the limit be decided? What should happen when the limit is hit? At what utilization level should we start alerting (i.e. what’s a soft limit for the queue size)? Should the queue be strictly FIFO? Those are good questions to ask! There’s no one-size-fits-all answer, but an important thing to realize is that these questions should be asked, if the Wait semantics are supported.

If we think about limits, the Prefer New semantics imply that the scheduling interval is also a hard limit. Conceptually, these are different things – you could imagine varying them independently if the latter was offered as a separate configuration knob.

Lastly, what are the fault models under which these semantics make sense?

  • Prefer New: J1’s running time exceeded the scheduling interval because of some non-deterministic reason (e.g. a rate limit being hit). We are optimistic, so it’s possible that J2 will not hit the same issue.

    In other words, we expect the job to be successful again sooner rather than later.

  • Wait: Same as above. Additionally, there is a non-functional requirement (or assumption!) that dropping jobs is not preferable.

  • Prefer Old: Assume that J1 is not hung, so it will probably finish if given more time.You would still want to have a hard limit separate from the scheduling interval to prevent runaway resource usage.

    We are pessimistic, so we assume that J2’s running time will also exceed the scheduling interval. So it’s wisest to cancel J2, so that at least J1 has a chance to finish.

I just wanted to do a simple thing

To jog your memory on what we were trying to do:

  • We wanted to run the same job with a fast path on weekdays (taking 2 hours) and a slow path on weekends (taking 7 hours).
  • We had one configuration knob for the scheduling interval. We were thinking of setting it to 3 hours to get more up-to-date results on weekdays.
  • We had a concurrency limit of 1 to avoid running multiple jobs at the same time.

Let’s try to see how such a configuration would fare on the weekend for the different semantics we discussed:For simplicity, assume that scheduling operations are instantaneous.

  • Prefer New: When the 7 hour job would hit the 3 hour mark, it would be canceled. A new 7 hour job would be triggered, and canceled after 3 hours again. Essentially, we’d waste 48 hours of compute across 16 jobs, because none of these would ever finish.
  • Wait: Over the 48 hours of the weekend, we’d trigger 16 jobs, with a total compute demand of 16×7 = 112 hours. If the queue limit was higher than (112-48)/7 = 64/7 = 9.15, we’d emerge out of the weekend with about 64 hours of pending work. However, the weekdays only offer (24×5×(3-2)/3) = 40 spare hours, so we wouldn’t finish draining this queue before the start of the subsequent weekend.
  • Prefer Old: In 48 hours, we could almost finish seven 7 hour jobs, with ~1 hour of pending work. We would’ve canceled 9/16 jobs. We’d get back on schedule quickly on Monday since we only have 1 hour of extra work left from the weekend.

So it seems like Prefer Old is the best fit here. Yes, it might still seem counter-intuitive; we’ll get to that shortly.

The other extra fun thing here is that if you were using the Wait strategy, you can easily end up masking the problem without actually helping. Maybe you got paged on the weekend for the queue being non-empty for an extended period of time. So you decide to increase the concurrency. You pick a good round number like 4. The new runners start to chew through the jobs, great! Now, you have these expensive jobs running for 7 hours, but most of their results are only useful for ~3 hours on the weekend, before they get superseded. 😬

Coming back to the appropriateness of Prefer Old, let’s look again at what we wrote for the fault models:

  • Prefer New: [..] we expect the job to be successful again sooner rather than later.
  • Wait: Same as above. Additionally, there is a non-functional requirement (or assumption!) that dropping jobs is not preferable.
  • Prefer Old: [..] we assume that J2 itself will also exceed the scheduling interval. [..]

For the schedule that we were trying to have on the weekend (7 hour jobs with a 3 hour interval), it closely matches the assumptions of Prefer Old very well, but it directly goes against the assumptions of Prefer New and Wait.

Additionally, our weekend workload is totally fine with dropping jobs in case something is running. But the Wait semantics would make us pay dearly for something we did not care about, and would likely require more expensive solutions on top.

If the Prefer Old semantics are not offered, you can’t really emulate them using the two primitives of regular scheduling and limiting concurrency. For the workload above, if you had a richer primitive such as cron-like scheduling, you could split the work into two separate jobs – one which only runs on weekdays, and one which only runs on weekends.

Things could’ve been worse

Earlier, we were thinking about fault models. That was cute, right?

Imagine a separate system compared to the one we’ve been talking about. There’s a global concurrency limit to keep costs under control. You have a bunch of different jobs coming in, mostly kicked off in the background. Everything goes in a FIFO queue. Nobody put in a global size limit.

Every few months, a customer comes in and tries the feature related to the job queue. They kick off some new jobs manually and see the queue position as 100K+ (or even, 1M+). They’re like “uhh, this feature doesn’t seem to work?” They escalate to a support engineer. It becomes a ticket on your kanban board. An engineer goes in and clears the queue by running a DELETE manually, with some displeasure.

The new manager asks “what can we do to solve this problem in a systemic way.” You spend a few days analyzing the problem. You look at the running times. You look at the queue growth charts. Ah, it’s hard to predict how long these jobs take, or how many of them come along. Maybe we reduce the jobs created? You look at the database columns, and you realize you don’t actually have enough information to go on to properly check which jobs came from the same configuration. The flexibility of job creation makes it seem quite tricky to retrofit a notion of job de-duplication.

The manager comes back “can we have an 80-20 solution?” You add a hack which tries to pick an already queued job which looks similar-ish, and overwrite it in-place when doing the enqueue operation. The queue is now mostly bounded in size, because newly queued jobs overwrite old ones if present, but there are still some configuration knobs which a customer could tune and break the system out of equilibrium. You declare it “user error” to tweak those configuration knobs, and move on to the next ticket.


While writing this, I was reminded of apenwarr’s line from The log/event processing pipeline you can’t have:

(I suddenly feel a lot of pity for myself after reading that paragraph. I think I am more scars than person at this point.)

Just randomly, for no good reason whatsoever.

ANYWAY

Here’s some hope as life’d be too depressing otherwise

It seems possible to design systems while thinking explicitly about queues, limits and fault models – and thinking about these seems to lend itself to designs which degrade more gracefully, and handle unexpected situations better, especially if the design is made under pessimistic assumptions.

In particular, explicitly spelling these out is valuable as a system designer, because it lets potential consumers evaluate the system quickly for their own workloads even before they go through a detailed step-by-step simulation in their head about what would happen in different situations.

Conversely, when you’re using a new system that you haven’t used before, it’s useful to find out this information if it’s documented (or you have access to the code). That way, you can check how well the designer’s assumptions apply to (or are violated by) your intended workload.

In particular, when there are configuration files involved, especially those which tweak control flow, not just data that flows through, it’s valuable to pause for a bit to dig into the various error cases that are involved in the corresponding control loop, because configuration files tend to hide this information away.

For systems which lend themselves to simple models, you might even be able to model the different cases using just pen-and-paper, like we’ve done above, without the need for simulations. Doing so can help illustrate gaps in our own thinking, where it’s easy to gloss over what happens when things don’t go quite as swimmingly as we expect.

The Daily Front Page 15 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Routers Meet the Prompt
article

LLM Networking with MikroTik

by gregsadetsky·▲ 103 points·57 comments·blog.greg.technology ↗
I’ve been refraining from saying ‘vibe networking’.

I’ve been refraining from saying ‘vibe networking’ or ‘vibkrotik’ or some other abomination, but it is true - I have been using LLMs to setup a few networks these last few months, and things have generally gone over swimmingly.

I’ve been a fan of MikroTik equipment for a while - the short story is that the equipment is reliable, inexpensive, and they cover a ton of networking use cases - IoT cell phone routers, regular routers, switches, point to point systems, etc.

One of the usual complaints about MikroTik has been its complex ui/configuration. In a sense, I don’t know if that’s true inasmuch as networking is complicated in itself - as in, it goes deeper than one thinks? Maybe there should be a iceberg-format meme about this. At the top you’d have “ip address”, and going deeper you’d see “the dude” and other you’ve-never-heard-of-them’s. mpls! igs! ospf!

The point I’m trying to make is yeah, networking can just be hard. I’ve been half-networking, amateur-ishly, for a while now - setting up networks for friends and friends’ offices, making cables, patching small panels etc. I almost certainly couldn’t pass an official “Certified Routing Engineer” cert - well, not without studying a lot (believe in yourself).

But you know, it hasn’t stopped me (and I suppose it shouldn’t?) from having fun and fixing people’s wifi (most people’s wifi problems can be categorized into… oh brother - I should make another post about this. Never use extenders.)


Where do LLMs fit in? Well, as elsewhere (in coding, etc.) they are a chaotic force multiplier - they definitely know how to configure MikroTiks and networking in general, but they also still get things wrong, go off-path, etc. As with coding, you can/should keep a tight leash, mis-trust and verify, but you can also make more progress faster ((remember the 2025 study that said that LLM speedups were not real - well…)).

And so these last few months I’ve been able to setup networks - small, for sure - and had a lot of fun giving claude code access to my devices and letting it do its thing. (As I discussed here, yes, I do mean dangerously-skip’ing-permissions).

I’ve compiled a short list of hopefully useful notes on this topic - in one case, I was migrating an existing network (a very small single-router-with-integrated-wifi to a router+switch+two wireless access points), while in two other cases the networks were net new.

In any case, here’s my list of LLM MikroTik tips and tricks:

  • even though mikrotiks can be configured over ssh, there’s a “death by a thousand cuts” that tends to happen when llms try to pipe text back and forth that way. the much better (ie more llm native) channel is to use the REST/JSON api.
  • as a generally-good-practice, I recommend disabling insecure services - the non secure api port, www, telnet and ftp
  • dump the entire config before any change, and dump it once you’re done. source version controlling those is great. having some automated backup tool (which I haven’t gotten around to build yet) would be the best
  • CAPsMAN is truly a huge wifi simplifier to setup multiple wireless access points - configuring it with llms is an absolute breeze
  • I often come back to the “trick” of asking multiple llms - antigravity, codex, opus and fable - to double check the config and come to a consensus to see if anything is missing or terribly wrong.
  • perhaps obvious, but before tearing down a network (when migrating to a mikrotik), take note of ssid’s, passwords, dhcp reservations
  • have a recovery runbook! take down the steps of what to do if you need to restore all of your devices’ configs from a good-known-place (hence, backing up your configs). run the runbook - an untested backup might as well be a file full of zeros.
  • as often is the case with llm’s, minimize the tasks and go step by step. yes, this is the “setup my network don’t make mistakes” joke - don’t do that. test after every config change. llms hallucinate!
  • very small thing, but it’s useful to setup ntp (time server) on all of the devices you’re configuring
  • also small, but for sanity’s sake, it’s good to give descriptive names/identify your devices - your router, switch, wireless access points. do the same for ports on a switch - it can be a bit of a pain to maintain as devices move around, but knowing which port connects to what comes in handy a lot.
  • make sure to update all of your devices so they’re running the same routeros version - llms also sometimes think they know how a command works but the syntax/options change over time - ask them to verify.

Oh, and finally!

I’ve been in situations (or maybe the LLM led me down this path..) where IP addresses are conflicting, you have multiple overlapping 192.168.88.x networks, and it’s generally a mess and hard to even connect to the router or switch, even if you’re physically connected to those devices over ethernet (which you should always be)

The best tool for this in my opinion is the L2 “MAC Telnet” ie a server that lets you telnet over the L2 (MAC-address) layer. It’s sort of the equivalent of using WinBox (which to my surprise is now cross-platform, and works quite well on Macs) - except that having an L2-layer telnet client allows your LLM to talk to your MikroTik devices. WinBox is a GUI software that LLMs can’t control.

For this, I deeply recommend MAC-Telnet - it will come in handy at the worst time ie when IP addresses don’t work! I/Claude just built a tiny Homebrew formula to make its installation easier, but you can also follow the original installation instructions - it’s the same code. I also made this small CLI just to make MAC-Telnet a bit more LLM-friendly to consume/use, but generally speaking, LLMs will figure out how to use a CLI tool by themselves. You don’t need an MCP server.

Have fun - and feel free to tell me I have it all wrong (it’s happened before). Bye!! xx

The Daily Front Page 16 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — The Teardown Bench
article

Teardown: A Generic 7-Port USB 3.0 Hub That Wasn't

by speckx·▲ 228 points·103 comments·goughlui.com ↗
Who doesn’t need more USB 3.0 ports now?

The recent end-of-financial-year sales were rather interesting – I found myself spending a lot more time browsing AliExpress than I did Amazon or eBay this year which is something I wouldn’t have envisioned in the past. I suppose the deals offered by the others just weren’t that good. While AliExpress was dangling cashbacks and coupons, I decided to make the most of it and buy some things that I needed.

I felt like a USB 3.0 hub with a nice number of ports would be a good thing to aim for. After all, who doesn’t need more USB 3.0 ports nowadays? But alas, while I thought the world of hubs have long advanced far enough to mean that even your average product would be decent – I didn’t expect to receive what I received …

Unbagging

As with generic, low-cost items, this one arrived in a bag rather than a box.

There isn’t much to identify who made the product, with the only name seemingly being a difficult-to-track-down Shenzhen Lishi (or is that Lishl) Technology Co. Ltd.

The hub has a captive USB-A cable, seven ports with individual indicators and switches. There are no other inclusions.

The body of the unit is entirely plastic, so definitely not like the mostly-metal of my Simplecom CHU810 which I had used in my optical drive tower-of-power. Visually similar products but definitely quite different in reality.

There is a small barrel jack for external power input, but there is no labelling regarding the input requirements. No power supply comes with this particular unit, but I suspect other (more expensive) listings may come with one. This one was listed at just shy of US$5.

It’s very anonymous, without even any branding or specifications on the rear.

The USB-A connector has a blue tongue and USB 3.0 contacts, but the plastic seems to have some mould flash.

Teardown

I had a feeling this wasn’t a quality product, so I decided to embark on a teardown right-away. This hub has a screwless design, so prying around the edge allows the posts to slide out of their friction-fit receptacles.

We’re now in and things don’t look right!

Count the pins! Six ports with only four pins, meaning USB 2.0 at the best. Only the top port has the right number of pins for USB 3.0 – it would seem this is a choice to keep costs down and “pretend” to be a USB 3.0 hub. It’s basically a USB 3.0 extension cable (or splitter, like the one I built) with a hub attached on the USB 2.0 lines. The port buttons appear to be hardware disconnect for VBUS, but that won’t stop self-powered devices from connecting.

They seemed to economise on solder so much that the USB 2.0 ports had absolutely no soldering on the shell, required to mechanically support the port and prevent it from flexing. The USB 3.0 port had just enough on the edge, but didn’t fill the full plated-through-hole and thus, is not as strong as it could be. All bad news.

From the top, we are reminded just how all ports are blue, “pretend” USB 3.0.

The hub is made from two HS8836A chips which are four-port USB 2.0 hubs. These are bare-basic devices that are common amongst hobbyists because they require no additional external crystal, but they also don’t do anything with power monitoring or port control indications. Instead, the indicator LED is just connected in series with a resistor across the VBUS of the port. One of the big downsides of the hub is that it’s a single-transaction-translator (STT) type hub which means that all of the ports on the four port hub “share” the same USB 1.1 bandwidth, rather than each port having a full USB 1.1 bandwidth “upconverted” to USB 2.0 back to the host. This can be particularly useful for heavy bandwidth applications with many hobbyist microcontrollers that might only be USB 1.1.

The captive cable has no connector and is soldered directly down to holes on the board. I wonder what that would do for the signal integrity, especially for USB 3.0. The power connection, however, appears to be just commoned with the external port without use of the jack’s internal switch. This means a connected external power supply will backfeed the computer and that could be a recipe for damage to the port or the computer and is something we had known about causing issues over 20 years ago, yet we’ve still got designs with this issue today. Spaces for capacitors for a bit of local bypassing? Bypassed to save money.

Three contacts on the jack, two of them commoned to a ground plane, the middle into a trace on the top-layer – a confirmation that the internal switch inside the jack is not being used.

Connected to a PC

While the product isn’t well built, at least it was detected by a computer when connected. Alas, it is detected as two USB 2.0 four-port hubs “stacked” on the first port – the “modern” way to build a 7-port hub. But they are USB 2.0 hubs … with the USB 3.0 lines wired through to the first port.

Conclusion

I usually do a decent job vetting the online shopping listings before purchasing. As a savvy consumer, I think I can “work out the lies” in the listing and pick the good ones. But alas, it seems the deception has grown so deep that it’s difficult to know and I suspect many purchasers that left good reviews didn’t even realise that they had been duped.

I should’ve known better than to trust a listing that was very clearly selling this product as a 7-port USB 3.0 hub. I should’ve known better than to trust the blue-coloured ports and the 4.7/5 ratings out of over 600 reviews. In the end, I basically got myself a device that extends one USB 3.0 port and hubs together six additional USB 2.0 devices. That was definitely not what I had intended. Add to this the mild danger of back-feeding your computer if an external power source is plugged in and absolutely zero power protections on any port, making this a mildly dangerous device (in terms of causing damage to other devices). The build quality is flimsy with solder economised to the point of not even bothering with the mechanical shell of the connector leading to ports “flexing” on every insertion-removal cycle. The world of USB-compatible devices is quite wild and while it might “sort-of” work, it also adds insult to injury by using cheaper single-transaction-translator (STT) hub chips.

As far as I can tell, a USB 3.0 hub shouldn’t be an expensive device given just how long they’ve been on the market for … so why are the good locally sold products so expensive? I wonder if any of them are truly as compliant as some of my older hubs were – I suspect many decent-branded hubs might not have per-port power monitoring nowadays.

But I guess this piece of equipment will have to stay with me. It’s going to take too much effort to send it back even if postage is free, with the risk of invalidating cashbacks that might end up costing me more. To say I am a bit miffed would be an understatement.

Appendix: USBTreeView Dump

The following is the data given by USBTreeView on the hub device.

    =========================== USB Port3 ===========================

Connection Status        : 0x01 (Device is connected)
Port Chain               : 1-3
Properties               : 0x01
 IsUserConnectable       : yes
 PortIsDebugCapable      : no
 PortHasMultiCompanions  : no
 PortConnectorIsTypeC    : no
ConnectionIndex          : 0x03 (Port 3)
CompanionIndex           : 0
 CompanionHubSymLnk      : USB#ROOT_HUB30#4&eced7c4&0&0#{f18a0e88-c30c-11d0-8815-00a0c906bed8}
 CompanionPortNumber     : 0x0D (Port 13)
 -> CompanionPortChain   : 1-13

      ========================== Summary =========================
Vendor ID                : 0x214B (AMECO TECHNOLOGIES (SHENZHEN) CO., LTD.)
Product ID               : 0x7260
Manufacturer String      : ---
Product String           : "USB2.0 HUB"
Serial                   : ---
USB Version              : 2.0 (480 Mbit/s)
Port maximum Speed       : High-Speed (Companion Port 1-13 is doing the SuperSpeed)
Device maximum Speed     : High-Speed
Device Connection Speed  : High-Speed
Self powered             : yes
Demanded Current         : 100 mA
Used Endpoints           : 2

      ========================== USB Hub =========================

Hub Symbolic Link        : USB#VID_214B&PID_7260#5&d714b9&0&3#{f18a0e88-c30c-11d0-8815-00a0c906bed8}

        +++++++++++++++++ Device Information ++++++++++++++++++
Device Description       : Generic USB Hub
BusReported Device Desc  : USB2.0 HUB
Device Path              : \\?\USB#VID_214B&PID_7260#5&d714b9&0&3#{f18a0e88-c30c-11d0-8815-00a0c906bed8} (GUID_DEVINTERFACE_USB_HUB)
Kernel Name              : \Device\USBPDO-5
Device ID                : USB\VID_214B&PID_7260\5&D714B9&0&3
Hardware IDs             : USB\VID_214B&PID_7260&REV_0100 USB\VID_214B&PID_7260
Driver KeyName           : {36fc9e60-c465-11cf-8056-444553540000}\0029 (GUID_DEVCLASS_USB)
Driver                   : \SystemRoot\System32\drivers\UsbHub3.sys (Version: 10.0.26100.8521  Date: 2026-06-10  Company: Microsoft Corporation)
Driver Inf               : C:\WINDOWS\inf\usbhub3.inf
Legacy BusType           : PNPBus
Class                    : USB
Class GUID               : {36fc9e60-c465-11cf-8056-444553540000} (GUID_DEVCLASS_USB)
Service                  : USBHUB3
Enumerator               : USB
Location Info            : Port_#0003.Hub_#0002
Address                  : 3
Location IDs             : PCIROOT(0)#PCI(1400)#USBROOT(0)#USB(3), ACPI(_SB_)#ACPI(PC00)#ACPI(XHCI)#ACPI(RHUB)#ACPI(HS03)
Container ID             : {c2780d0e-7689-11f1-921d-40d133f4e3da}
Manufacturer Info        : (Standard USB HUBs)
Capabilities             : 0x84 (Removable, SurpriseRemovalOK)
Status                   : 0x0180600A (DN_DRIVER_LOADED, DN_STARTED, DN_DISABLEABLE, DN_REMOVABLE, DN_NT_ENUMERATOR, DN_NT_DRIVER)
First Install Date       : 2026-07-03 13:00:40
Last Arrival Date        : 2026-07-03 13:00:39
EnhancedPowerMgmtEnabled : 0
IdleInWorkingState       : 1
Power State              : D0 (supported: D0, D1, D2, D3, wake from D0, wake from D1, wake from D2)

        +++++++++++++++++ Registry USB Flags +++++++++++++++++
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\usbflags\214B72600100
 osvc                    : REG_BINARY 00 00

        ------------------- USB Hub Descriptor -----------------
bDescriptorLength        : 0x09 (9 bytes)
bDescriptorType          : 0x29 (Hub Descriptor)
bNumberOfPorts           : 0x04 (4 Ports)
wHubCharacteristics      : 0x00E0
 Power switching         : Ganged
 Compound device         : No
 Over-current protection : Global
 TT Think Time           : At most 32 FS bit times
 Port Indicators         : Supported
bPowerOnToPowerGood      : 0x32 (100 ms)
bHubControlCurrent       : 0x64 (100 mA)
DeviceRemovable          : 0x00
 Bit 0                   : 0 (reserved, any value)
 Bit 1                   : 0 (Device at Port 1 is removable)
 Bit 2                   : 0 (Device at Port 2 is removable)
 Bit 3                   : 0 (Device at Port 3 is removable)
 Bit 4                   : 0 (Device at Port 4 is removable)
 Bit 5                   : 0 (unused, must be 0)
 Bit 6                   : 0 (unused, must be 0)
 Bit 7                   : 0 (unused, must be 0)
PowerControlMask         : 0xFF
HubIsBusPowered          : 0x00 (Self Powered)

      ---------------- Extended USB Hub Descriptor ---------------
HubType                  : 0x02 (Usb20Hub - hub descriptor is defined in USB 2.0 and 1.1 specifications)
HighestPortNumber        : 0x04 (Port 4 is the highest)
UsbHubDescriptor         : identical with "USB Hub Descriptor" shown above

        ----------------- USB Hub Capabilities ----------------
HubIs2xCapable           : 1 (Is 2.x capable)

        --------------- USB Hub Capabilities Ex ---------------
HighSpeedCapable         : 1 (Yes)
HighSpeed                : 1 (Yes)
MultiTtCapable           : 0 (No)
HubIsMultiTt             : 0 (No)
ArmedWakeOnConnect       : 0 (No)
IsBusPowered             : 0 (No)
IsRoot                   : 0 (No)

        ---------------- Connection Information ---------------
Connection Index         : 0x03 (Port 3)
Connection Status        : 0x01 (DeviceConnected)
Current Config Value     : 0x01 (Configuration 1)
Device Address           : 0x04 (4)
Is Hub                   : 0x01 (yes)
Device Bus Speed         : 0x02 (High-Speed)
Number of open Pipes     : 0x01 (1 pipe to data endpoints)
Pipe[0]                  : EndpointID=1  Direction=IN   ScheduleOffset=0  Type=Interrupt  wMaxPacketSize=0x01    bInterval=12  -> 1040 Bits/ms = 130000 Bytes/s

        --------------- Connection Information V2 -------------
Connection Index         : 0x03 (3)
Length                   : 0x10 (16 bytes)
SupportedUsbProtocols    : 0x03
 Usb110                  : 1 (yes, port supports USB 1.1)
 Usb200                  : 1 (yes, port supports USB 2.0)
 Usb300                  : 0 (no, port not supports USB 3.0) -> but Companion Port 1-13 does
 ReservedMBZ             : 0x00
Flags                    : 0x00
 DevIsOpAtSsOrHigher     : 0 (Device is not operating at SuperSpeed or higher)
 DevIsSsCapOrHigher      : 0 (Device is not SuperSpeed capable or higher)
 DevIsOpAtSsPlusOrHigher : 0 (Device is not operating at SuperSpeedPlus or higher)
 DevIsSsPlusCapOrHigher  : 0 (Device is not SuperSpeedPlus capable or higher)
 ReservedMBZ             : 0x00

    ---------------------- Device Descriptor ----------------------
bLength                  : 0x12 (18 bytes)
bDescriptorType          : 0x01 (Device Descriptor)
bcdUSB                   : 0x200 (USB Version 2.0)
bDeviceClass             : 0x09 (Hub)
bDeviceSubClass          : 0x00
bDeviceProtocol          : 0x01 (Hi-speed hub with single TT)
bMaxPacketSize0          : 0x40 (64 bytes)
idVendor                 : 0x214B (AMECO TECHNOLOGIES (SHENZHEN) CO., LTD.)
idProduct                : 0x7260
bcdDevice                : 0x0100
iManufacturer            : 0x00 (No String Descriptor)
iProduct                 : 0x01 (String Descriptor 1)
 *!*ERROR  String descriptor not found
iSerialNumber            : 0x00 (No String Descriptor)
bNumConfigurations       : 0x01 (1 Configuration)

    ------------------ Configuration Descriptor -------------------
bLength                  : 0x09 (9 bytes)
bDescriptorType          : 0x02 (Configuration Descriptor)
wTotalLength             : 0x0019 (25 bytes)
bNumInterfaces           : 0x01 (1 Interface)
bConfigurationValue      : 0x01 (Configuration 1)
iConfiguration           : 0x00 (No String Descriptor)
bmAttributes             : 0xE0
 D7: Reserved, set 1     : 0x01
 D6: Self Powered        : 0x01 (yes)
 D5: Remote Wakeup       : 0x01 (yes)
 D4..0: Reserved, set 0  : 0x00
MaxPower                 : 0x32 (100 mA)

        ---------------- Interface Descriptor -----------------
bLength                  : 0x09 (9 bytes)
bDescriptorType          : 0x04 (Interface Descriptor)
bInterfaceNumber         : 0x00 (Interface 0)
bAlternateSetting        : 0x00
bNumEndpoints            : 0x01 (1 Endpoint)
bInterfaceClass          : 0x09 (Hub)
bInterfaceSubClass       : 0x00
bInterfaceProtocol       : 0x00
iInterface               : 0x00 (No String Descriptor)

        ----------------- Endpoint Descriptor -----------------
bLength                  : 0x07 (7 bytes)
bDescriptorType          : 0x05 (Endpoint Descriptor)
bEndpointAddress         : 0x81 (Direction=IN EndpointID=1)
bmAttributes             : 0x03 (TransferType=Interrupt)
wMaxPacketSize           : 0x0001
 Bits 15..13             : 0x00 (reserved, must be zero)
 Bits 12..11             : 0x00 (0 additional transactions per microframe -> allows 1..1024 bytes per packet)
 Bits 10..0              : 0x01 (1 byte per packet)
bInterval                : 0x0C (2048 microframes -> 256 ms)

    --------- Device Qualifier Descriptor (for Full-Speed) --------
bLength                  : 0x0A (10 bytes)
bDescriptorType          : 0x06 (Device_qualifier Descriptor)
bcdUSB                   : 0x200 (USB Version 2.0)
bDeviceClass             : 0x09 (Hub)
bDeviceSubClass          : 0x00
bDeviceProtocol          : 0x00 (Hi-speed hub without TT)
bMaxPacketSize0          : 0x40 (64 Bytes)
bNumConfigurations       : 0x01 (1 other-speed configuration)
bReserved                : 0x00

    ------------ Other Speed Configuration Descriptor -------------
bLength                  : 0x09 (9 bytes)
bDescriptorType          : 0x07 (Other_speed_configuration Descriptor)
wTotalLength             : 0x0019 (25 bytes)
bNumInterfaces           : 0x01 (1 Interface)
bConfigurationValue      : 0x01 (Configuration 1)
iConfiguration           : 0x00 (No String Descriptor)
bmAttributes             : 0xE0
 D7: Reserved, set 1     : 0x01
 D6: Self Powered        : 0x01 (yes)
 D5: Remote Wakeup       : 0x01 (yes)
 D4..0: Reserved, set 0  : 0x00
MaxPower                 : 0x32 (100 mA)

        ---------------- Interface Descriptor -----------------
bLength                  : 0x09 (9 bytes)
bDescriptorType          : 0x04 (Interface Descriptor)
bInterfaceNumber         : 0x00 (Interface 0)
bAlternateSetting        : 0x00
bNumEndpoints            : 0x01 (1 Endpoint)
bInterfaceClass          : 0x09 (Hub)
bInterfaceSubClass       : 0x00
bInterfaceProtocol       : 0x00
iInterface               : 0x00 (No String Descriptor)

        ----------------- Endpoint Descriptor -----------------
bLength                  : 0x07 (7 bytes)
bDescriptorType          : 0x05 (Endpoint Descriptor)
bEndpointAddress         : 0x81 (Direction=IN EndpointID=1)
bmAttributes             : 0x03 (TransferType=Interrupt)
wMaxPacketSize           : 0x0001 (1 byte)
bInterval                : 0xFF (255 ms)

      -------------------- String Descriptors -------------------
none
The Daily Front Page 17 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Protocols and Paperwork
article

Bluesky Trademarks ATProto

by chaosharmonic·▲ 196 points·148 comments·atproto.com ↗
It can feel a bit weird when lawyers get involved.

Bluesky recently acquired the rights to the trademark for “ATPROTOCOL” and its variants—including “AT Protocol” and “atproto”—from another company that was threatening to take legal action preventing the company and others from using the term. Now that Bluesky owns it, the atproto community’s continued use of the mark can be protected.

Given the nature of what we’re trying to accomplish with the protocol, it can feel a bit weird when lawyers get involved. We’re trying to shield atproto developers from having all their hard work undone by someone abusing the legal system. That really is it.

You can read a bit more about it on the new trademarks page or read the full policy (as far as legal documents go, this one’s pretty tidy; Bluesky’s lawyers are the best).

I’ve worked with Bluesky’s legal team to put together the FAQ below. If you have questions not answered here, feel free to reach out to trademarks@atproto.com.

FAQ

Is Bluesky going to start charging for a license to use atproto? No, this is primarily a defensive measure. Our crack legal team tells me if you don’t enforce your mark, then anyone can come along and claim it. We acquired the rights and are going to defend it from bad actors while making it freely available to the ecosystem. Discussions about licensing fees will only arise if a commercial enterprise wants to use the mark (for profit) as part of its branding or product offerings.

Do I need a license? Most everyday use cases don't require a license. You can create projects that refer to the protocol, say your app is compatible with atproto (as long as it actualy is!), discuss it in the community, write docs, even name open source packages or tooling (such as atproto-feed-tool or "AT Protocol SDK"). In general, be descriptive, be accurate, don't imply your implementation is official or endorsed, and keep your own name or brand at least as prominent as the mark.

Who does need a license? When your mark becomes a brand rather than a description. This includes product, company, or service names built around AT Protocol; paid or sponsored events; merchandise, registered domain names, official-sounding certifications, or any official use of the AT Protocol logo. Obviously, impersonation, scams, or presenting an incompatible protocol as atproto is not allowed.

We intend to offer the mark to people currently using it and to anyone else who wishes to use it in good faith. Read the terms and reach out if you believe you need a license.

What do you mean by good faith? The exact terms spell that out, but basically we want existing developers and service providers to continue to freely use “AT Protocol” and “atproto” to build the ecosystem while preventing anyone from coming along and trying to claim that they are atproto if they’re not.

Why is the trademark owned by Bluesky PBC and not another entity? The most straightforward answer is: practicality. We were able to acquire the trademark using the resources of Bluesky the public benefit corporation and want to offer trademark protection as soon as possible. We plan to transfer that ownership to an appropriate, independent protocol governance organization in the future.

Why not use the PLC association? The PLC association is chartered for the specific purpose of stewarding the directory. Giving it ownership of the trademark is not part of the mission of that organization.

Do I have to let people know I’ve been granted a license? No.

Isn’t this whole trademark thing antithetical to the entire ethos of a decentralized open-source protocol? Not really. We can’t share something that someone else owns. And, the law in the U.S. requires us to defend our mark or risk having someone else claim it as theirs. We do wish we could do it with fewer legal requirements. We’ve taken inspiration from other open-source projects that have taken a similar approach, including Wikimedia, Red Hat, Rust, Python Foundation, Apache, Mozilla, Linux, and Debian.

The Daily Front Page 18 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Hardware With Edges
article

I also filed the corners off my MacBook

by maxbrt·▲ 309 points·222 comments·brt.fyi ↗
A laptop will be frequently used on a lap.

I like my new-to-me MacBook because it enables me to create more stuff compared to my now almost 8 year old Thinkpad I had before, mainly due to battery and screen reasons. There is one thing about it that I cannot wrap my head around from a design perspective and it’s the sharp edges especially around the wrist area. On a flat surface it’s no problem at all, but low and behold a laptop will be frequently used on a lap, meaning the wrists will touch the sharp edge at an angle which is very uncomfortable.

I’ve found some posts online about people that have filed the edges off of their Macs before, like this nice one by Kent Walters. In my case however, almost no one freaks out about this because no one actually notices it. It went well, so I thought why not document the process.

Note I am absolutely not a good craftsman, so do not use this as a guide!

I thought a long time about how to do it so as not to have it result in a wavy bezel. Although I really wanted to use this project as an excuse to buy a random orbital sander, I very quickly pivoted from this idea because I would probably cause more damage with it than I would with a hand file. Another idea was to 3D print some sort of chamfer to use as a guide, just to have the initial part cleanly taken off at an angle. I played around with this idea for a while but concluded that I could probably not get to the required level of precision I wanted, especially since it would have to hold the file/sandpaper. I ended up just using general purpose metal file I had at hand and progressive sand paper (in block form, strongly recommended).

Some tape helped me to mark the areas from below and above the bezel that I wanted to have file off. This worked really well. Of course I taped off the trackpad, the keyboard and headphone plug etc. to avoid any residue from getting inside. Additionally, I used very little soapy water that I applied to the sandpaper throughout to keep the dust contained somewhat. The initial filing with the general metal file was a bit scary, but it did not take off too much and the tape helped me to achieve an even level. The sandpaper blocks also helped with that, and I went up to 1200 coarseness.

The tricky part for me were these pointy dots on the little gap in the middle. I did not want to go too heavy here with the filing so I used some model making files very, very lightly and then just used the 1200 sandpaper until I was satisfied with the result. In the end, I was careful to remove any dust that had accumulate, for which this little air blower squeegy thing came in handy.

This is a “blue” M4 MacBook Air, so I’m curious how it will look over time due to the anodized aluminum. Another note would be to test it out a bit and see how it feels. I have quite large arms so when typing I also touch the corners, so I had to go back and file them too, but that might not be necessary depending on how you do it.


What I liked about the Thinkpad was that I could just chuck it anywhere without worrying about it. I made it a point when I bought this Mac to treat it just the same way. Ultimately, it is a tool and should be used appropriately. Even if it is nice and shiny, if modifying it would make it serve it’s primary purpose as a tool better, it’s worth a consideration. So I like this recent wave of encouragement around similar modifications, and it is also far easier and approachable than it seems initially.

The Daily Front Page 19 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Show HN: Lua Goes Native
show hn

Show HN: Clx – Compile Lua to Native Executables Through C++20

by _samt_·▲ 139 points·32 comments·github.com ↗
Cross-platform ahead-of-time Lua compiler.

CLX Logo

Cross-platform ahead-of-time Lua compiler

Lua 5.5 C++20 License: MIT ko-fi

clx is a cross-platform ahead-of-time Lua compiler and runtime that generates standalone native executables through modern C++ toolchains. clx is not trying to be the fastest Lua implementation in every workload.

Its goal is to provide:

  • ahead-of-time native compilation,
  • deployable standalone executables,
  • predictable runtime performance,
  • fast startup times,
  • integration with existing C++ toolchains,
  • and strong optimization opportunities through modern native compilers.

Quick Start

git clone https://github.com/samyeyo/clx.git
cd clx
./build.sh install       # or build.bat install on Windows
clx examples/hello/hello.lua
./hello

Hello clx !

Features

  • Competitive performance with strong results on many AOT-friendly workloads
  • No bytecode interpreter overhead — compiles to standalone native executables
  • Aggressive optimizations — leverages modern optimizations via Clang/GCC/MSVC
  • Small binaries — size-oriented builds can produce very compact executables (Lua programs can be under 100 KB with --minimal)
  • Targets Lua 5.5 compatibility — coroutines, metamethods, tables, and more
  • 16-byte tagged values — 8-byte payload + separate type tag
  • Inline string optimization (strings ≤ 6 bytes stored in value, no allocation)
  • Fast-path table access caches
  • Lightweight AOT-oriented runtime
  • clx C++ API: develop portable native modules using a value-oriented API

Examples built with clx

clx comes with examples, that uses a Sokol clx binary module for graphics, and demonstrate native desktop application development with standard Lua code.

Pong

Pong

pong : a complete game written in Lua and compiled into a standalone native executable.

Mandelbrot viewer

Mandelbrot

Mandelbrot : a Mandelbrot viewer written in Lua and compiled into a standalone native executable.

Project status

clx is currently in beta. The compiler is already capable of compiling non-trivial Lua applications, but compatibility work and optimization improvements are ongoing.

Requirements

  • Linux: g++ (recommended for TCO) or clang++
  • macOS: clang++ (Xcode) or g++ via Homebrew (for TCO)
  • Windows: g++ (LLVM) or MSVC
  • CMake 3.15+ for building

Note: The compiler used to build clx is fixed at build time via CMake and used for all Lua script compilation. This ensures ABI compatibility between the runtime libraries and generated code. Rebuild clx with a different compiler if you need a different backend.

Build

POSIX

./build.sh              # Release (default)
./build.sh debug        # Debug
./build.sh clean        # Removes build/ + /usr/local install
./build.sh install      # Release + install to /usr/local
./build.sh uninstall    # Removes installed files in /usr/local

Windows

./build.bat              # Release (default)
./build.bat debug        # Debug
./build.bat clean        # Removes build/ + ./bin and ./lib
./build.bat install      # Release + install to /bin and ./lib
./build.bat uninstall    # Removes previously installed clx

Also works directly with CMake:

mkdir -p build && cmake -S . -B build && cmake --build build

Once compiled, you will find :

  • build/clx — The compiler executable
  • build/libclx.a — Static runtime library
  • build/libclx_size.a — Static runtime library optimized for size

Usage

./build/clx file.lua                         # Compile to executable (default flags)
./build/clx --object file.lua                # Object file (.o/.obj)
./build/clx --static file.lua                # Static clx module (.a/.lib)
./build/clx --cpp file.lua                   # Generate C++ source, don't compile
./build/clx file.lua -O2                     # Forward unknown clx flags to the backend compiler
./build/clx file.lua --output f.exe          # Custom output name
./build/clx file.lua --debug                 # No optimizations, debug symbols
./build/clx file.lua --minimal               # base + package modules only
./build/clx file.lua --fast                  # Optimize for speed
./build/clx file.lua --size                  # Optimize for size (default)
./build/clx --version                        # Print version
./build/clx --help                           # Display help

Compatibility

clx targets Lua 5.5 compatibility.

Current status:

  • Core language: largely implemented
  • Tables and metatables: implemented
  • Coroutines: implemented
  • Modules: implemented
  • Most standard libraries: implemented

See compatibility.md for detailed status.

Known limitations

  • load() / dofile() / loadfile() / string.dump() — dynamic code loading requires a runtime interpreter
  • debug module — very complex in a pure AOT model
  • The traditional Lua C API is not supported.
  • Binary modules should be written using the clx C++ API.

Test suite

./tests/run.sh              # POSIX
./tests/run.bat             # Windows

Each .lua in tests/ is compiled to a binary and executed. Tests print [OK]/[FAIL] per assertion.

Benchmarks

Results are expressed in speedup factor against standard Lua 5.5 interpreter :

Script lua 5.5 LuaJIT clx --fast fib.lua 0.311s (1.00x) 0.045s (6.91x) 0.005s (62.20x) arraysum.lua 0.128s (1.00x) 0.052s (2.46x) 0.031s (4.13x) spectralnorm.lua 0.310s (1.00x) 0.018s (17.22x) 0.029s (10.69x) canada.lua 0.372s (1.00x) 0.142s (2.62x) 0.286s (1.30x) warmup.lua 0.006s (1.00x) 0.005s (1.20x) 0.005s (1.20x)

Measured on Intel® Core™ i5 Ultra 125U CPU @ 4.30GHz · Linux · GCC 13.3.0 · Avg of 10 runs

Full benchmarks are available in clx benchmarks

Documentation

Documentation is available in the doc/ directory, including :

  • Getting Started
  • CLI Reference
  • Compatibility Status
  • Modules and Migration Guide
  • C++ API Reference
  • Runtime Internals
  • Architecture Overview
  • Optimizations
  • Benchmarks

See Documentation Index

License

clx is MIT Licensed — Copyright (c) 2026 Tine Samir

The Daily Front Page 20 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Science: A Leaky World Nearby
article

Helium escaping from atmosphere of nearby rocky exoplanet in a habitable zone

by anyonecancode·▲ 145 points·51 comments·science.org ↗
Helium escaping from the planet’s atmosphere.

Abstract

Observations of highly irradiated gas giant exoplanets have shown helium escaping from their atmospheres. There is limited evidence for atmospheres on rocky exoplanets, perhaps because they have already escaped. We report near-infrared spectroscopic observations of LHS 1140b, a rocky exoplanet that orbits in the habitable zone of a nearby low-mass star. The transit spectra show absorption by helium escaping from the planet’s atmosphere. Helium absorption is detected in 2024 but not in 2025, indicating time-variable atmospheric escape. We interpret these results as indicating an upper atmosphere dominated by helium and depleted in hydrogen, with other volatile species trapped at lower altitudes, consistent with atmospheric fractionation models. No helium absorption is detected for LHS 1140c, a smaller and more strongly irradiated exoplanet in the same system.

Theoretical models predict that the atmospheres of rocky exoplanets can regulate the climate, shield the surface from ionizing radiation, and enable the presence of liquid water (1, 2). Atmospheres have been observed on large, gas-rich, highly irradiated exoplanets (3, 4). Observing atmospheres on smaller, cooler, rocky exoplanets is technically challenging because they are dwarfed (in size and brightness) by the stars that they orbit. Those challenges can be reduced by studying planets that orbit red dwarf stars (M dwarfs), whose small sizes and low brightness reduce the contrast between the star and any orbiting planet. However, M dwarfs are more active than Sun-like stars, emitting high-energy radiation that can drive atmospheric escape from closely orbiting planets. It is therefore unclear whether such planets can retain their atmospheres for billions of years (5, 6). Observations of small, rocky exoplanets have mostly revealed airless worlds or atmospheres too tenuous to detect (7, 8), with some debated evidence for atmospheres (911).

The LHS 1140 system

The transiting rocky exoplanet LHS 1140b has a mass of 5.60 ± 0.19 Earth masses (M⊕) and a radius of 1.730 ± 0.025 Earth radii (R⊕). These values are consistent with an Earth-like bulk composition with an additional a low-density component, such as an atmosphere or a high abundance of water. It has an orbital period of 24.7 days and receives 42% of the stellar irradiation received by Earth, giving it an equilibrium temperature Teq = 226 ± 4 K (assuming zero albedo), placing it in the liquid-water habitable zone (12, 13). There is another transiting rocky planet in the same system, LHS 1140c (1.91 ± 0.06M⊕ and 1.272 ± 0.026 R⊕) with an orbital period of 3.78 days, which receives about five times the irradiation received by Earth (14). The host star LHS 1140 (also cataloged as GJ 3053) is an old [> 3 Gyr (12, 13, 15,)] inactive (12, 16) M dwarf located 14.96 ± 0.01 parsecs from the Sun (13).

Spectroscopic observations of LHS 1140

We observed the LHS 1140 system using the Warm Infrared Echelle Spectrograph to Realize Extreme Dispersion (WINERED) mounted on the Magellan Clay telescope at Las Campanas Observatory, as part of the WINERED Helium Consortium project. On 2024 September 23, we observed the system for 6.5 hours, covering one transit of each planet, separated by 39 min. A total of 70 spectra were collected in total: 35 out-of-transit, 12 during the transit of LHS 1140c, and 23 during the transit of LHS 1140b. We used the WINERED Automatic Reduction Pipeline [WARP (17)] for the initial data reduction (18). To construct time series spectra and search for excess absorption (wavelength-dependent absorption of stellar radiation by the planet’s atmosphere), we divided each spectrum by a stellar template Fout, the mean stellar flux of all out-of-transit spectra (Fig. 1A). The resulting time series (Fig. 1B) shows an absorption feature near 10,833 Å during the transit, pre-ingress, and post-egress of LHS 1140b. This feature is consistent with the presence of metastable helium.

Fig. 1. Time series spectra for LHS 1140b observed in 2024.

(A) The average out-of-transit stellar template spectrum, constructed from exposures with no helium absorption apparent in the time series (see Fig. 3). Arrows indicate absorption features from Earth’s atmosphere that are masked in (B). (B) Time series spectra of LHS 1140b in the stellar rest frame. Colors indicate the percentage difference from the stellar template in (A). Horizontal lines enclose the expected transits of LHS 1140b (black dashed) and LHS 1140c (white dotted). The vertical dashed white lines indicate the expected positions of helium absorption lines, moving with the same velocity as LHS 1140b. Cross hatching indicates data that were excluded due to Earth’s atmospheric features (A) and a single noisy exposure.

We produced a planetary transmission spectrum of LHS 1140b (Fig. 2) by computing the mean of all in-transit excess absorption spectra in the planetary rest frame (18). This transmission spectrum contains correlated noise, which we modeled using a Gaussian process [GP; (18)]. Metastable helium is expected to produce a triplet of closely spaced absorption lines, which we modeled with three Gaussian profiles at 10,832.057, 10,833.217 and 10,833.306 Å [rest wavelengths in vacuum (19)]; the latter two lines are blended at the resolution of the WINERED spectra. We used a Markov chain Monte Carlo (MCMC) analysis to fit the model to the data and determine the uncertainties in our measurements. The MCMC process followed a Bayesian retrieval framework with five free parameters: the three peak amplitudes, a shared peak width, and a shared Doppler shift (18).

Fig. 2. Raw transmission spectrum for LHS 1140b in 2024.

Excess absorption (black dots) is plotted as the mean of the in-transit spectra shown in Fig. 1. Pre-ingress and post-egress spectra have been excluded, even when they contain evidence for helium absorption. The vertical blue lines indicate the rest wavelengths of the helium absorption lines. The horizontal gray line indicates zero absorption. The gap is due to the data excluded in Fig. 1B. Error bars show 1σ uncertainties.

We report the median values and 16 to 84% confidence intervals of the MCMC posterior probability distributions. The excess absorption depth is 1.24−0.23+0.22% at the position of the two blended long-wavelength peaks and 0.25−0.12+0.14%  for the single short-wavelength peak, with a Doppler shift of 0.072−0.073+0.080 Å relative to the planet velocity, equivalent to 2.0−2.2+2.0 km s−1. This corresponds to an equivalent opaque radius (the planetary radius including an opaque atmospheric layer that would produce the observed absorption feature) of 1.52 times the radius of LHS 1140b. The full-width at half-maximum (FWHM) of the blended helium absorption lines is 0.86−0.27+0.15 Å, corresponding to 23.9−7.5+4.2 km s−1. The measured ratio of the blended red peak amplitude to the blue peak amplitude is 6.7−3.1+12.7. This is consistent with the ratio of 8 we expect due to the fine structure of the helium triplet (20, 21); this assumes negligible optical depth (τ≪1) in the thin upper atmosphere where metastable helium can persist.

We also fitted the nine pre-ingress spectra with apparent helium absorption in the time series (Fig. 1B) using the same model and MCMC process. The resulting absorption depth is 1.01−0.34+0.31% and the Doppler shift is 0.3−7.5+3.5 km s−1. This is consistent with escaping helium ahead of the planet in its orbit (a leading tail). Leading tails have been previously observed for some gas giant exoplanets with escaping atmospheres (2224) and interpreted as resulting from stellar winds or interactions between the magnetic fields of the star and planet (25, 26). We also performed the same analysis for the eight post-egress spectra, finding an absorption depth of ≤0.76% (1σ upper limit) and a Doppler shift of 0.53−0.42+0.33 Å, equivalent to 14.7−9.0+11.8 km s−1, which we regard as tentative evidence of a trailing tail. We calculated the mean helium absorption between 10,833 and 10,834 Å to construct a transit light curve of the blended red absorption lines (Fig. 3A). The transmission spectra of the LHS 1140b transit, leading tail, and possible trailing tail are shown in Fig. 3, B to D.

Fig. 3. Helium absorption as a function of time and line profiles observed in 2024.

(A) Mean helium absorption, calculated from 10,833 to 10,834 Å, as a function of time, measured relative to the mid-transit time (t0) of LHS 1140b. Gray points are all observed spectra, and black points have been binned by a factor of three (17 min). The pink shaded regions indicate the data used to construct the stellar template (Fig. 1A). The blue shaded regions show the pre-ingress and post-egress data used to construct the transmission spectra shown in (C) and (D), which were chosen by eye from Fig. 1B. Vertical lines show the expected transit times of LHS 1140b (gray) and LHS 1140c (green). (B) The transmission spectrum constructed from the in-transit data, (C) pre-ingress data, and (D) post-egress data. In each panel, black dots show the data after subtraction of the Gaussian process model. Thick blue lines show the best-fitting absorption line models, which have the labeled values of the absorption depth δ and Doppler shift Δλ. Thin purple lines show 100 random samples drawn from the MCMC fitting process. All error bars show 1σ uncertainties.

We repeated the same analysis for the transit of LHS 1140c in 2024 (see supplementary text), but found no evidence of helium absorption (fig. S1). The transit of LHS 1140b in 2025 also shows no evidence of helium absorption (Fig. 4 and fig. S2). To investigate whether these differences between transits could be explained by choices in the data reduction process, the 2024 and 2025 datasets for LHS 1140b were independently re-reduced using a different reduction code; we found no difference in the results [fig. S3; (18)].

Fig. 4. Comparison between the 2024 and 2025 observations of LHS 1140b.

Data points are the raw transmission spectrum in 2025 (gray points) and the GP-subtracted transmission spectrum in 2024 (black points, as in Fig. 2). Error bars show 1σ uncertainties. (A) Colored lines are p-winds models with different XUV flux (see legend) and escape rates (assumed to be proportional to the XUV flux), compared to the best-fitting model of the 2024 data (dark blue line). The gray dashed line shows the detection limit of 0.6%, determined through injection-recovery tests (see supplementary text). (B) Same as (A), but varying the outflow temperature at fixed XUV flux. Several models with low XUV flux or high temperature predict helium absorption depths at or below the detection limit.

Interpretation as an atmospheric outflow

We attribute the helium absorption observed for LHS 1140b in 2024 to a hydrodynamic outflow from an atmosphere, driven by heating due to stellar X-ray and extreme-ultraviolet (XUV, collectively) radiation (27). We considered several alternative explanations, including stellar activity or contamination of the spectra by Earth’s atmosphere, but exclude each of these (see supplementary text).

To determine the physical properties of the atmospheric outflow, we modeled the observed spectra of LHS 1140b using the p-winds code (28). This code models an escaping atmosphere as a one-dimensional, isothermal outflow (29) then forward models a predicted transmission spectrum using a radiative transfer model. We used an MCMC process to estimate the atmospheric mass-loss rate, the temperature of the outflow Twind, the hydrogen to helium atomic number ratio (H:He) of the outflow, and the line-of-sight bulk velocity shift vwind, which corresponds to the Doppler shift of the helium absorption lines. This calculation requires an input stellar spectral energy distribution (SED) to determine the ionization rates and number densities of hydrogen and helium as functions of altitude. We analyzed archival X-ray observations of LHS 1140 using the X-ray Multi-Mirror Mission (XMM-Newton), to determine the X-ray flux of the host star. This was then used to scale previously published semi-empirical SEDs of GJ 1132 and GJ 699 (Barnard’s star), two M dwarfs with similar spectral types, masses, and rotation rates to LHS 1140 [fig. S4; (18)].

For the GJ 1132 (or GJ 699) SED, the MCMC modeling indicates an atmospheric mass-loss rate 2.03−0.58+0.67×108 (4.22−1.00+1.14×​108) g s−1, H:He ratio 1.01−0.50+0.85×10−3 (1.32−0.56+0.89×10−3), Twind =5160−50+46 (5850−81.9+78.1) K, and vwind = 2260−300+330 (2270−290+330) m s−1 (Fig. 5 and figs. S5 and S6). For comparison, we used previous methods (30) to calculate the mass-loss rate expected for an outflow limited by XUV energy from stellar irradiation, finding (6.2 to 29) × 107 g s−1. To estimate the incident XUV flux for this calculation, we integrated the scaled SEDs for GJ 1132 and GJ 699 between 10 and 1300 Å. An alternative mechanism for atmospheric escape, core-powered mass loss, predicts a much smaller mass-loss rate (31, 32), so we conclude that the atmospheric escape is predominantly driven by the XUV stellar radiation. These calculations are not sensitive to the composition of the outflow assumed in the core-powered mass loss scenario.

Fig. 5. Models of mass-loss rate, outflow temperature, and H:He ratio for LHS 1140b in 2024.

(A) Models of atmospheric mass-loss rate as a function of outflow temperature, Twind. Color indicates the likelihood, normalized so zero corresponds to the maximum likelihood. Black contours indicate 1σ and 3σ deviations from the maximum likelihood. Data points with 1σ error bars are the best-fitting values from the MCMC analysis of the 2024 spectrum of LHS 1140b, assuming an SED normalized to GJ 699 (black) or GJ 1132 (green). All models assumed the GJ 1132-normalized SED and fixed vwind to the best-fitting value from the MCMC analysis. The dashed gray line shows a theoretical upper limit on the escape rate (see text). The dashed red line shows the theoretical minimum escape rate required to drag atomic oxygen in the outflow. The dashed black and green lines show the energy-limited escape rates calculated with the SEDs normalized by GJ 699 and GJ 1132, respectively. (B) Same as (A), but as a function of the H:He ratio. Data points with dotted error bars assumed 10 times higher stellar XUV flux.

The age of the LHS 1140 system is not well constrained. The star’s long rotation period of 131 days (12) implies an age ≳3.1 Gyr, assuming the typical spin-down rates of M dwarfs (15). Assuming the atmosphere was initially 1.5% of the planetary mass (31), the atmosphere of LHS 1140 b would have been completely removed if the mean escape rate was ≳5​  ​×​ 109 g s−1. We interpret this as an upper limit on the present-day escape rate, because the XUV flux was probably orders of magnitude higher in the past (33).

LHS 1140 has an X-ray luminosity of 6.5 × 10−6 times its bolometric (all wavelengths) luminosity (18), which is low for an M dwarf. The bolometric luminosity is 0.0038 ± 0.0003 times the Sun’s luminosity, and LHS 1140b receives 42% of the stellar energy received by Earth. The X-ray flux received by LHS 1140b is therefore 2.7 to 16 times that received by Earth, where the range corresponds to periods of high and low solar activity, over the Sun’s 11-year cycle (18). Our p-winds models indicate that for H:He ratios ≳0.01 in the escaping wind, atmospheric hydrogen would attenuate the relatively low XUV flux ≤911 Å, which would prevent the ionization of neutral helium and the subsequent production of metastable helium. We therefore conclude that the upper atmosphere of LHS 1140b has a low abundance of hydrogen relative to helium.

Atmospheric composition

LHS 1140b’s transmission spectrum has been constrained by previous observations (3438). Those studies did not detect an atmosphere but ruled out cloud-free, hydrogen-dominated atmospheres with metallicities (abundance of elements heavier than helium) up to 1,000 times that of the Sun. This constraint and our retrieved H:He ratio of ∼1 ​​×​ 10−3 are consistent with predictions from a previous model of planetary atmospheric escape and magma ocean evolution (39). That study predicted that LHS 1140b might have a helium-dominated atmosphere due to mass fractionation by hydrodynamic atmospheric escape. Other models have also predicted escape-driven formation of helium-dominated atmospheres for exoplanets similar in size and temperature (40, 41).

The measured mass of LHS 1140b (13) implies a bulk density slightly less than that of an airless rocky planet. A helium-dominated atmosphere would have a height ∼1.7× smaller than that of a solar-composition, hydrogen-dominated atmosphere, so likely would have been detected in previous studies (36, 37) if it were free of high-altitude clouds or hazes (which can obscure features in a transmission spectrum). Clouds and hazes are unlikely to form at the equilibrium temperature of LHS 1140b’s atmosphere (42). Previous observations of LHS 1140b have ruled out water clouds and, tentatively, hazes composed of methane or hydrogen sulfide (36, 37). While our observations indicate that the upper atmosphere is probably helium-dominated, it is possible that the bulk atmosphere (at lower altitudes) is more metal-rich.

For a helium-dominated wind escaping at a rate of 3 × ​108 g s−1, the mass below which atmospheric species can be dragged into the escaping outflow (crossover mass), is < 9 amu at any temperature < 10,000 K (43). In other words, our inferred escape rate of helium is insufficient to carry off any species with mass ≥ 9 amu. We calculate that the minimum atmospheric escape rate required to drag atomic oxygen is 2 ​×​ 109 g s−1 (Fig. 5). We therefore expect the observed outflow to gradually enrich the atmosphere in heavier elements that are left behind, including O, C and N.

An alternative explanation previously proposed for the bulk density of LHS 1140b is an Earth-like ratio of iron and rock plus 9 to 19% water by mass (13). This explanation assumed an Earth-like atmosphere with 1 bar surface pressure, and did not account for escaping helium. However, if the planet does contain a substantial amount of water, it would probably be shielded from escape via condensation (cold trapping) in the upper part of the atmosphere’s convective layer (tropopause), given the cool equilibrium temperature of 226 K. A common approximation of the tropopause temperature is the skin temperature Tskin≡2−1/4Teq, which is 194 K for LHS 1140b (44). Atmospheric species with boiling points above this value are expected to be cold trapped, so are less likely to escape. For example, we estimate the molar concentration of water at the tropopause fH2O=Psat,trop/Psat,trop, where Psat,trop is the water saturation vapor pressure at the skin temperature and Ptrop is the tropopause pressure, which we assume is 0.1 bar based on its value for the Solar System planets and previous radiative transfer models of LHS 1140b (13, 37, 45). We find fH2O≈7 ppm at the tropopause of LHS 1140b (compared to ∼3 ppm on Earth). This is consistent with an outflow that lacks hydrogen, which would be produced by photodissociation of water if it reached the upper atmosphere. The H:He ratio we estimated for the upper atmosphere implies that highly volatile, reduced species, such as CH4, could be depleted in the lower atmosphere because their vertical transport would not be inhibited by the cold trap.

Variable helium escape

The non-detection of helium in 2025 could be due to variable atmospheric escape from LHS 1140b. To investigate this possibility, we generated helium line models with the p-winds software, assuming XUV fluxes and escape rates from 1% to 33% of their fiducial values (0.033 W m2 and 2.03−0.58+0.67 ​×​108 g s−1) for the SED derived using GJ 1132 spectra, and Twind from 5160 K (the best fitting temperature from the 2024 data) to 6400 K. Several of these models predict helium absorption depths ≲0.6% (Fig. 4), the detection limit of our 2025 observations (see supplementary text). We therefore conclude that there could have been undetectable helium escape in 2025 for XUV flux or upper atmosphere temperature variations observed for other stars (4648) (see supplementary text).

Constraints on the cosmic shoreline

The cosmic shoreline is a proposed boundary that separates airless rocky planets from those that retain atmospheres for billions of years (5, 6). The two planets in the LHS 1140 system are on either side of the proposed cosmic shoreline. Our non-detection of helium absorption by LHS 1140c is consistent with the previously measured dayside emission, which indicates that the planet has little to no atmosphere (10). Therefore, this system is consistent with the proposed position of the cosmic shoreline.

Acknowledgments

We thank Evgenya Shkolnik for helpful comments on stellar helium lines. This paper is based on WINERED data gathered with the 6.5 m Clay/Magellan II Telescope located at Las Campanas Observatory, Chile. We thank the staff at Las Campanas Observatory for their efforts and particularly thank Carla Fuentes and Hernan Nuñez for assistance with telescope operations on the nights of our observations. We thank the WINERED team: Noriyuki Matsunaga, Shogo Otsubo, Yuki Saragaku, and Tomomi Takeuchi, for their assistance and support of instrument operations.

Funding:

A. McW. and J.T. acknowledge a Carnegie Venture Grant, which funded the installation and fabrication of ancillary equipment to enable WINERED at Las Campanas Observatory. T.C. was supported by NASA through the NASA Hubble Fellowship grant HST-HF2-51527.001-A awarded by the Space Telescope Science Institute, which is operated by the Association of Universities for Research in Astronomy, Inc., for NASA, under contract NAS5-26555. A.H. was supported by the National Science Foundation Graduate Research Fellowship under Grant No. 2141064 and the MIT Dean of Science Fellowship. M.Z. and J.A.D. were supported by the Heising-Simons Foundation's 51 Pegasi b fellowship (FP107579). S.V. acknowledges support from the Mt. Cuba Astronomical Foundation. R.W. acknowledges support from Leverhulme Center for Life in the Universe grant G119167, LBAG/312. W.M. acknowledges support from the AEThER program, funded in part by the Alfred P. Sloan Foundation under grant #G202114194, and the Carnegie Postdoctoral Fellowship. N.L.W. was supported by NASA through a grant (for program #2512) from the Space Telescope Science Institute, which is operated by the Association of Universities for Research in Astronomy, Inc., under NASA contract NAS 5-03127. M.L.-M. was supported by NASA contracts NAS 5-26555 and NAS 5-03127 to the Associated Universities for Research in Astronomy for the operation of the Hubble and James Webb Space Telescope Science Operations Centers at STScI.

Author contributions:

C.C., S.V., A.G.M., and A.H. planned and performed the WINERED observations. C.C. led the data analysis and interpretation. S.V. wrote data analysis code. J.A.D. proposed and collected the XMM-Newton data, which T.C. analyzed and wrote the corresponding text. R.W. and D.C. supported the data reduction and interpretation. A. G.M. and M.L.-M. contributed to the data analysis and interpretation, particularly the Gaussian process fitting. L.A.D.S. supported the atmospheric retrieval. M.Z. performed the independent data reduction. W.M. and Z.L. contributed theoretical interpretation. N.L.W. and J.T. contributed to the observational protocols. A.McW. investigated stellar contamination. C.C. led manuscript writing, with contributions from J.T., A.McW., and T.C.

Competing interests:

There are no competing interests to declare.

Data, code, and materials availability:

The WINERED observations in 2024 and 2025, data reduction scripts, p-winds models, and all other code necessary to reproduce our findings are archived at Zenodo (49). No physical materials were generated in this work.

License information:

Copyright © 2026 the authors, some rights reserved; exclusive licensee American Association for the Advancement of Science. No claim to original US government works. https://www.science.org/about/science-licenses-journal-article-reuse

Supplementary Materials

This PDF file includes:

Materials and Methods

Supplementary Text

Figs. S1 to S13

Table S1

References (50114)

The Daily Front Page 21 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Science: The Canopy Yields a Secret
article

'Likweli': A new monkey species discovered in the Congo Basin

by gmays·▲ 117 points·29 comments·news.yale.edu ↗
A new Colobus monkey species inhabiting high forest canopies.

Yale researchers have helped to describe a new species of Colobus monkey living high in the forest canopies of the Democratic Republic of Congo.

Artist rendering of the new monkey species

An artist’s depiction of Likweli, a new Colobus monkey species that inhabits forest canopies in the Democratic Republic of Congo and is difficult to photograph.

Illustration by Kimio Honda

An international team of scientists, including Yale researchers, has described a new Colobus monkey species inhabiting high forest canopies in the Democratic Republic of Congo (DRC). It is just the fifth new species of African monkey discovered in the past 75 years.

The species, Colobus congoensis, is a rare and cryptic monkey largely unknown even among people living within its range. But those who are familiar with the small, black monkeys — an arboreal creature marked by distinctive orange-cream patches around their mouths and noses — call them “Likweli,” which the researchers recommend remain the species’ common name. 

The identification of Likweli as distinct from other Colobus monkey species is supported by evidence drawn from its physical traits, genetics, and vocalizations.

VIDEO: Likweli lives high in the treetops of Lomami National Park in Congo. (Credit: Bernard Bonanga)

“Our team evaluated multiple datasets that all reached the same conclusion: Likweli is a distinct species of Colobus monkey we haven’t seen before,” said Julia Arenson, a postdoctoral fellow in Yale’s Department of Anthropology and the Yale Institute for Biospheric Studies and coauthor of the study. “Discovering a primate species is exceptionally rare, especially from populations previously unknown to science.”

The documentation of the Likweli, she said, also highlights the importance of protecting the rich biodiversity of Lomami National Park, an 8,874-square-kilometer park known for its rare and endemic species.

In the past 75 years, only eight new ape and Old World monkey species from populations previously unknown to science have been discovered. Those include Lesula, a monkey species discovered in 2012 in the Congo Basin by a team that included several coauthors of the current study. 

The latest discovery is described in an article published on July 15  in the journal PLOS One. For the study, Arenson and Eric Sargis, professor of anthropology in Yale’s Faculty of Arts and Sciences, collaborated with researchers from the Lukuru Wildlife Research Foundation and Lomami National Park in DRC, Florida Atlantic University, and Hunter College of the City University of New York. All research was conducted under the authorization of the Congolese Institute for Nature Conservation (ICCN), an agency of the DRC government.

Eric Sargis and Julia Arenson with monkey skulls

Eric Sargis and Julia Arenson

Photo by Allie Barton

John and Terese Hart, conservation biologists with the Lukuru Wildlife Research Foundation and curatorial affiliates at the Yale Peabody Museum, are coauthors of the study.

“The discovery and documentation of the Likweli never would have happened without our team of Congolese explorer naturalists,” said John Hart, the study’s lead author. “These field leaders recognized when they were in front of something they did not know. They put in the extra effort needed to document and confirm it. Their attentive fieldwork, covering hundreds of hours and thousands of kilometers of reconnaissance across trackless forest, produced some of the most remarkable information for this new find.”

The monkeys, which were most frequently observed in small groups of about six individuals, are difficult to spot and photograph because they live high in dense forest canopy

From 2018 to 2022, the researchers recorded 114 observations of Likweli over a range of about 1,700 square kilometers in Lomami National Park and its buffer zone on highlands located between three tributaries of the Congo River: the Tshuapa, Lomami, and Lualaba. The monkeys, which were most frequently observed in small groups of about six individuals, are difficult to spot and photograph because they live high in dense forest canopy, the researchers noted. 

“Only after 10 years of exploring the Lomami Forest did we have enough clear views and convincing photos to allow us to say that there was yet another new monkey,” said Terese Hart. “This primate was not only new – it was extremely rare and with a limited range.”

In addition, the researchers spoke with residents of 52 villages bordering or located within Likweli’s range. Residents in only eight villages reported knowledge of the species and could accurately describe it. Since people in the region typically have detailed knowledge of local flora and fauna, this supports the notion that Likweli is a cryptic species, the researchers said. 

In April 2021, ICCN officers confiscated three deceased Likweli specimens from hunters within the Lomami National Park buffer zone. Those specimens — two adult females and one adult male — were acquired by the researchers with authorization from ICCN. The skins and skeletons are now housed in the Yale Peabody Museum’s mammalogy collection.

Likweli are small, long tailed, and have black coats. Long, black hairs frame their foreheads and faces. Their small size, striking orange-cream patches on their faces, and white patches on their backsides underneath their tails distinguish them from Colobus satanas, its sister species and the only other Colobus monkey with all-black coats, the researchers said. Characteristics of its teeth and skull also differ from those of Colobus satanas, according to the study.

The researchers also compared Likweli vocalizations with those of other Colobus species. They found that its roars are similar to vocalizations of its sister species (Colobus satanas) in some ways but differ in sequence and frequency, among other aspects. 

Based on a genetic analysis, the researchers estimated that the most recent common ancestor of Colobus congoensis and Colobus satanas existed between about 4.7 and 5.8 million years ago, which represents the deepest split in lineages among Colobus monkeys, further suggesting that Likweli is a distinct species. 

The researchers note that the new species and its sister species, though very similar, inhabit ranges that are at least 1,200 kilometers apart, with the latter living to the west of the former. 

Based on Likweli’s limited range and small population, coupled with the effects of increased hunting and human encroachment on the monkeys’ habitat, the researchers recommend that the species be designated as endangered. 

“This is a rare discovery with significant conservation implications,” said Sargis, a curator of mammalogy and vertebrate paleontology at the Yale Peabody Museum and director of the Yale Institute for Biospheric Studies. “Our analyses provide strong evidence that this is a distinct new species that is endangered. 

“Documenting new primate species like Likweli and Lesula illustrates the incredible biodiversity in this region and points to the importance of Lomami National Park and other conservation efforts in protecting that diversity.”

The Daily Front Page 22 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Business Briefs
article

NotebookLM is now Gemini Notebook

by xnx·▲ 364 points·179 comments·blog.google ↗

We’re renaming NotebookLM to Gemini Notebook. It's the same standalone product, now doing more across the Google ecosystem and updated with a secure cloud computer.

We introduced NotebookLM at Google I/O 2023 as Project Tailwind with a simple goal: help people learn. Now, more than 30 million people and over 600,000 organizations are using it to transform how they work, from business owners creating interactive onboarding materials to students converting notes into audio and video summaries.

Today, we’re renaming NotebookLM to Gemini Notebook. It remains a standalone product focused on being your premier research tool, but it will now do more across the Google ecosystem, including inside the Gemini app and Google Search.

Explore under-the-hood upgrades

To make your research more accurate and powerful, we’ve started to roll out an update that gives every notebook a secure cloud computer. This allows Gemini Notebook to write and execute code natively, helping you conduct complex data analysis grounded in your sources. This is available today for Google AI Ultra users and Workspace business customers with AI Ultra Access and AI Expanded Access. It will roll out to all Pro users on the web over the coming weeks, enabling entirely new output formats and deeper analysis.

Take your notebooks everywhere

Just like a physical notebook, your digital notebooks should go wherever you work. You can already access and create notebooks directly within the Gemini app, with full cross-app syncing between the Gemini app and the standalone Gemini Notebook experience. Soon, we’ll also bring notebooks directly into AI Mode in Search.

To everyone who has been with us since Project Tailwind in 2023, thank you. We’re excited to keep building this ecosystem with you. Try out the new features and let us know what you learn.

The Daily Front Page 23 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Learning Shelf
repository

The Little Book of Reinforcement Learning

by mustaphah·▲ 204 points·26 comments·github.com ↗
★ 1,144⑂ 56 forks Python

The Little Book of Reinforcement Learning

This is the associated GitHub page of the Little Book of Reinforcement Learning.

This book is a short introduction to Reinforcement Learning, from the basics to applied algorithms.

book

In this repo, along with the book itself, you can find the supplementary material of the book. More precisely:

  • under the algos/ folder, the Pytorch-based implementation of the different algorihms covered in the book, from MC to PPO.
  • under the supplementary/ folder, you can find detailed explanations and rigorous proofs for the dynamic programming algorithms briefly covered in the book. This is a document I wrote in 2021.

More material is subject to be added along the way in this repo.

You can print one for youself here.

Versions of the book :

  • V1 (June 2026)

The book is distributed under a non-commercial Creative Commons license (CC BY-SA 4.0).

article

Mathematics of Data Science

by Anon84·▲ 207 points·14 comments·arxiv.org ↗

Abstract:This book is about the mathematical foundations of data science.

  1. Introduction
  2. Curses, Blessings, and Surprises in High Dimensions
  3. Singular Value Decomposition and Principal Component Analysis
  4. Linear Regression and Regularization
  5. Graphs, Networks, and Clustering
  6. Nonlinear Dimension Reduction and Diffusion Maps
  7. Linear Dimension Reduction via Random Projections
  8. Optimization for Data Science
  9. Classification
  10. A Mathematical Introduction to Deep Learning
  11. Large Sample Limit of Graph Laplacians
  12. Community
  13. Concentration of Measure and Gaussian Analysis
  14. Matrix Concentration Inequalities
  15. Compressive Sensing and Sparsity
  16. Low-Rank Matrix Recovery
The Daily Front Page 24 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Arts, Weather, and Other Returns
article

CD sales growth outpaced vinyl in the first half of 2026

by speckx·▲ 179 points·204 comments·consequence.net ↗

For the better part of the last decade, vinyl has been the face of music’s physical revival. But according to Luminate’s 2026 Midyear Report, another format is quietly making an even bigger comeback: the compact disc.

During the first half of 2026, CD sales surged 16% to 16.3 million units, soundly outpacing vinyl’s comparatively modest 2.4% growth. While BTS’ blockbuster album ARIRANG played a major role in the increase, Luminate found the resurgence extends well beyond K-pop. Even after removing BTS and the broader K-pop catalog from the equation, CD sales still climbed 6.7% year over year.

Get BTS Tickets Here

The report also points to a broader boom in physical music. Total US physical album sales: LPs, CDs, and cassettes rose 7.8% through the first half of the year, reaching 38.2 million units.

One explanation may be changing listening habits among younger fans. According to the report, 60% of Gen Z listeners now say they most often listen to music from the 1990s or earlier, a huge increase from 18% in 2021. Whether driven by nostalgia, discovery via streaming, or a wanting to own a tangible piece of an artist’s work, younger audiences appear to be embracing physical formats.

Where fans are buying music is changing, too. Indie record stores continue to account for the lion’s share of physical album sales, but stores like Target and Walmart posted the biggest gains during the first half of 2026, capturing nearly 30% of the market. Luminate attributes much of that growth to K-pop’s collector-driven culture, with BTS, ENHYPEN, and ATEEZ ranking among the biggest sellers at those mass-market retailers thanks to deluxe packaging, exclusive editions, and other collectible offerings.

While vinyl isn’t going anywhere, Luminate’s latest findings suggest the physical music revival has entered a new chapter. The iridescent gleam of CDs is no longer just a relic of the late ’90s and early 2000s; they’re functioning as collectibles, fan merchandise, and a way for listeners to support the artists they love.

CDs aren’t the only format enjoying a second act. Earlier this year, Maxell introduced a Bluetooth cassette player, underscoring how nostalgia-fueled physical media continues to resonate with music fans.

article

1,300 Beautiful Wildlife Illustrations from the 19th Century Now Restored

by gslin·▲ 250 points·49 comments·openculture.com ↗

Today, if you want to know about any of the flo­ra or fau­na that sur­round us, you have only to look it up online. After you get your fill of knowl­edge, you can decide whether or not you want to ven­ture out into the world and see your object of inter­est in its nat­ur­al envi­ron­ment (or a con­trolled sim­u­la­tion there­of). In the Vic­to­ri­an era, things worked a bit dif­fer­ent­ly. Ide­al­ly, you’d have grown up in a house­hold, or at least had access to an insti­tu­tion, with the com­plete set of The Nat­u­ral­ist’s Library, a series of more than 40 vol­umes on every­thing from the birds and the bees to the quadrupeds and the mar­su­pi­alia. Print­ed in a rel­a­tive­ly small for­mat and priced at six shillings each, they brought the intel­lec­tu­al fruits of the nat­u­ral­ist’s enter­prise clos­er to the reach of the every­man than ever before.

While these books offered a good deal of infor­ma­tive text, includ­ing mem­oirs from var­i­ous famous nat­u­ral­ists of the time, their imme­di­ate attrac­tion had more to do with their glo­ri­ous illus­tra­tions, in which col­ored exam­ples of each species popped right out of its black-and-white habi­tat. These more than 1,300 col­or plates, some of the finest that could be seen in any pub­li­ca­tion of sim­i­lar scale in the mid-nine­teenth cen­tu­ry, pre­sent­ed an attrac­tive project to the design­er Nicholas Rougeux, whose work we’ve pre­vi­ous­ly fea­tured here on Open Cul­ture.

Hav­ing already restored and cre­at­ed dig­i­tal ver­sions of Euclid­’s Ele­ments, Pierre-Joseph Red­outé’s Les Ros­es and Les Lil­i­acées, Eliz­a­beth Twin­ing’s Illus­tra­tions of the Nat­ur­al Orders of Plants, and Daniel Berke­ley Updike’s Print­ing Types, among oth­er books, he’s now put online a com­plete repro­duc­tion of the Nat­u­ral­ist’s Library — with, as usu­al, a blog post about the painstak­ing restora­tion and dig­i­tal re-cre­ation process.

This time, Rougeux has includ­ed a sec­tion about his use of arti­fi­cial intel­li­gence, which actu­al­ly did its part to bring The Nat­u­ral­ist’s Library to his atten­tion in the first place. Not only did AI tools then help him unearth need­ed sources and fill in visu­al gaps, they also came in handy when he was brain­storm­ing cov­er con­cepts for a print­ed ver­sion. Though Rougeux’s restora­tion is pri­mar­i­ly a web site, free to all to explore, you can also buy your own hand­some, large-for­mat phys­i­cal copy of Plates of the Nat­u­ral­ist’s Library for $295.11 USD.

Eas­i­er on the wal­let is the series of posters he’s made with the same illus­tra­tions, each of which presents one of these cat­e­gories of crea­tures great or small at a glance. The orig­i­nal Nat­u­ral­ist’s Library inspired gen­er­a­tions to ded­i­cate them­selves to under­stand­ing the nat­ur­al world; these new ver­sions, whether in print, online, or on the wall, will no doubt encour­age the enthu­si­asm of more than a few bud­ding nat­u­ral­ists in the gen­er­a­tions to come. Vis­it the repro­duc­tion of the Nat­u­ral­ist’s Library here.

The Daily Front Page 25 of 26
Thursday, July 16, 2026 The Daily Front No. 7 — Colophon

That's the Front for Today

Issue No. 7 — Thursday, July 16, 2026 — went to press 2026-07-18 at 08:35 UTC.

About This Magazine

The Daily Front is a daily digital magazine assembled from the stories that reached the front page of Hacker News on Thursday, July 16, 2026. Headlines, points, and comment counts are recorded as they stood at press time. All articles remain the property of their original authors — every piece links back to its source and its discussion thread.

How It Was Made

Fetched, cleaned, and typeset by an automated pipeline. An editor model laid out the pages, chose the highlights, and briefed the cover illustrator — 31 model calls and 331k tokens in total. Set in Jacquard 12, Playfair Display, Source Serif 4, and IBM Plex Mono, all served via Google Fonts under the SIL Open Font License.

The Cover

The cover illustration was commissioned with this prompt:

A simple semi-abstract acrylic painting for an editorial cover, with no text, no logos, no letters, no numbers, no readable symbols anywhere​.

Depict a quiet abstract sunrise over a simplified coastal landscape. At the center, place a large open mechanical flower or open book-flower hybrid, painted in loose geometric petals, glowing softly as if it represents open intelligence. The form should be recognizable but not detailed or technical. Around it, add a few small human-like silhouettes tending thin cable-like vines, very minimal and painterly.

On one side, include two unfinished bridge-like structures: one in rusty orange tones, the other in clean silver-gray, suggesting transformation and rebuilding. Near the water at the bottom, paint a few floating record-like circles and blank film-reel shapes slowly fading into the sea, hinting at lost music and disappearing movies.

Style and medium: semi-abstract acrylic on canvas, broad brushstrokes, palette-knife texture, layered matte paint, simplified shapes, soft gradients, visible canvas texture, elegant negative space. Palette of dawn peach, pale gold, turquoise, ivory, rust orange, silver-gray, muted lavender, and sea-glass green. Calm, modern, poetic, bittersweet, highly aesthetic, simple editorial cover composition, not cluttered.

Absolutely no text, no captions, no logos, no readable characters, no UI elements anywhere in the image.

Production Ledger

StageModelCallsTokens InTokens Out
extractgpt-5.5 30 219,395 88,380
layoutgpt-5.5 1 19,223 4,079

The Publisher

Published by Johnny.

Support the Press

If The Daily Front brightens your morning, consider supporting its publisher.

Credits & Contact

All content — articles, posts, comments, and the images within them — belongs to its original authors and is reproduced here to point readers back to the source. Full credit goes to those creators; every item links to its original and its Hacker News discussion.

If you are an author and would like your content removed from an issue, write to hi@johnnys.page and it will be taken down.

Feedback is always welcome at the same address: hi@johnnys.page.

Credit where credit is due.

Every page of this issue began as someone else's work — these are the original sources, linked in full.

  1. Kimi K3: Open Frontier Intelligence by vincent_s — kimi.com·HN discussion ↗
  2. How Our Rust-to-Zig Rewrite Is Going by jorangreef — rtfeldman.com·HN discussion ↗
  3. The lost joy of music piracy by mcgin — pigeonsandplanes.com·HN discussion ↗
  4. Sony deletes more movies from the accounts of people who ‘bought’ them by nekusar — techdirt.com·HN discussion ↗
  5. Microsoft Comic Chat is now open source by jervant — opensource.microsoft.com·HN discussion ↗
  6. Decoy Font by ray__ — mixfont.com·HN discussion ↗
  7. Detecting LLM-Generated Texts with “Classical” Machine Learning by uneven9434 — blog.lyc8503.net·HN discussion ↗
  8. The LLM Critics Are Right. I Use LLMs Anyway by JeremyTheo — theocharis.dev·HN discussion ↗
  9. $100 AI Music Video: Claude Fable 5 vs. GPT-5.6 Sol by hershyb_ — tryai.dev·HN discussion ↗
  10. LM Studio Bionic: the AI agent for open models by minimaxir — lmstudio.ai·HN discussion ↗
  11. Command Line Interface Guidelines by subset — clig.dev·HN discussion ↗
  12. If you want to create a button from scratch, you must first create the universe by treve — madcampos.dev·HN discussion ↗
  13. Job queues are deceptively tricky by ingve — typesanitizer.com·HN discussion ↗
  14. LLM Networking with MikroTik by gregsadetsky — blog.greg.technology·HN discussion ↗
  15. Teardown: A Generic 7-Port USB 3.0 Hub That Wasn't by speckx — goughlui.com·HN discussion ↗
  16. Bluesky Trademarks ATProto by chaosharmonic — atproto.com·HN discussion ↗
  17. I also filed the corners off my MacBook by maxbrt — brt.fyi·HN discussion ↗
  18. Show HN: Clx – Compile Lua to Native Executables Through C++20 by _samt_ — github.com·HN discussion ↗
  19. Helium escaping from atmosphere of nearby rocky exoplanet in a habitable zone by anyonecancode — science.org·HN discussion ↗
  20. 'Likweli': A new monkey species discovered in the Congo Basin by gmays — news.yale.edu·HN discussion ↗
  21. NotebookLM is now Gemini Notebook by xnx — blog.google·HN discussion ↗
  22. OnePlus halts operations in USA and Europe by pilililo2 — community.oneplus.com·HN discussion ↗
  23. Ente – Opening Our Books by Sherex — ente.com·HN discussion ↗
  24. Immersive Linear Algebra Book with Interactive Figures (2015) by srean — immersivemath.com·HN discussion ↗
  25. The Little Book of Reinforcement Learning by mustaphah — github.com·HN discussion ↗
  26. Mathematics of Data Science by Anon84 — arxiv.org·HN discussion ↗
  27. How to Train a Gen AI Kick Drum Model on Your Old Linux Desktop with 6GB VRAM by zhinit — zhinit.dev·HN discussion ↗
  28. CD sales growth outpaced vinyl in the first half of 2026 by speckx — consequence.net·HN discussion ↗
  29. 1,300 Beautiful Wildlife Illustrations from the 19th Century Now Restored by gslin — openculture.com·HN discussion ↗
  30. Goes-19 weather satellite enters Safe Hold mode by yabones — spaceweather.gov·HN discussion ↗

Browse all issues in the archive →