# Adding Custom Language Support to Shiki · Aliou Diallo URL: https://aliou.me/notes/2025-08-06-custom-shiki-languages/ While writing my Ghostty blog post, I wanted syntax highlighting for the configuration snippets. Since Shiki doesn’t support Ghostty’s config format, I added a custom language definition. const ghostty = { name: 'ghostty', scopeName: 'source.ghostty', fileTypes: ['ghostty'], repository: {}, patterns: [ { include: '#strings', }, { name: 'comment.line.number-sign.ghostty', match: '^\\s*#.*$', }, { match: '\\b(font-family|font-feature)\\b', name: 'keyword.other.ghostty', }, ], }; I only defined patterns for comments (#) and the font keywords I was actually using. The grammar follows TextMate rules but doesn’t try to implement the full Ghostty spec - just enough for my examples. highlighter = await createHighlighter({ themes: ['github-dark', 'github-light'], langs: ['ruby', 'javascript', 'json', 'elixir', 'typescript', ghostty], }); --- # Smooth Theme-Aware Images with CSS Transitions · Aliou Diallo URL: https://aliou.me/notes/2025-08-12-theme-aware-images/ When Ghostty came out, I wrote this blog post which contained a few screenshots. I forgot how I managed to have both light and dark mode work seamlessly in Eleventy, so I figured I’d write it down for future me or anyone that sees this. The Pattern I use an Eleventy shortcode that conditionally renders different images based on theme preference. The basic version wraps images in figure tags with captions: config.addShortcode('image', (src: string, alt: string, withDarkMode = false) => { const lastDotIndex = src.lastIndexOf('.'); const basePath = src.slice(0, lastDotIndex); const extension = src.slice(lastDotIndex); const lightPath = `${basePath}-light${extension}`; const darkPath = `${basePath}-dark${extension}`; return `<figure>${ withDarkMode ? `<img class="not-dark:hidden" src="${darkPath}" alt="${alt}">` + `<img class="dark:hidden" src="${lightPath}" alt="${alt}">` : `<img src="${src}" alt="${alt}">` }<figcaption>${alt}</figcaption></figure>`; }); Smooth Transitions The images would swap instantly when toggling themes, which felt jarring. Adding Tailwind’s transition utilities creates a cross-fade effect: withDarkMode ? `<img class="not-dark:hidden not-dark:opacity-0 dark:opacity-100 starting:dark:opacity-0 transition-discrete transition-opacity duration-1000" src="${darkPath}" alt="${alt}">` + `<img class="dark:hidden dark:opacity-0 not-dark:opacity-100 starting:not-dark:opacity-0 transition-discrete transition-opacity duration-1000" src="${lightPath}" alt="${alt}">` : `<img src="${src}" alt="${alt}">` The starting: modifiers prevent the fade animation on initial page load, which I borrowed from how Tailwind’s own docs handle theme switching. Usage Now {% raw %}{% image "ghostty-screenshot.png" "Terminal with ligatures" true %}{% endraw %} automatically looks for ghostty-screenshot-light.png and ghostty-screenshot-dark.png, then smoothly transitions between them when you toggle themes. Trade-offs Benefits: Smooth 1-second cross-fade between theme variants Both images preload—no flicker on theme switch Simple to maintain with clear naming conventions Drawbacks: Downloads both variants even if user never switches themes Requires maintaining two versions of every screenshot Notes This approach works well for static sites where you control every image. For dynamic content or high-traffic sites, you’d want lazy loading for the hidden variant or CSS filters for automatic adjustments. References Figured how to create custom shortcode after reading this article by Anh. --- # In-Memory DuckDB with GCS Parquet Files in Cube · Aliou Diallo URL: https://aliou.me/notes/2025-08-21-duckdb-inmemory-gcs-parquet-cube/ I needed to quickly investigate some data and used the following pattern to do so. Using the model of an e-commerce store as example, here’s what I did. The Setup First, setup a cube.js defining the dbType: module.exports = { dbType: 'duckdb', } Then, define the driverFactory, and configure the access to the GCS bucket: const driverFactory = async () => { const { DuckDBDriver } = require('@cubejs-backend/duckdb-driver'); return new DuckDBDriver({ database: ':memory:', initSql: ` INSTALL httpfs; LOAD httpfs; CREATE SECRET ( TYPE gcs, KEY_ID 'YOUR_KEY_ID', SECRET 'YOUR_SECRET_ID' ); `, }); } Finally, add to initSql the commands to setup our views and access our data: CREATE VIEW orders AS SELECT * FROM read_parquet('gs://tmp-bucket/orders/*.parquet'); CREATE VIEW products AS SELECT * FROM read_parquet('gs://tmp-bucket/products/*.parquet'); CREATE VIEW line_items AS SELECT * FROM read_parquet('gs://tmp-bucket/line_items/*.parquet'); CREATE VIEW users AS SELECT * FROM read_parquet('gs://tmp-bucket/users/*.parquet'); CREATE VIEW inventory_transactions AS SELECT * FROM read_parquet('gs://tmp-bucket/inventory/*.parquet'); Key Implementation Details Views, Not Tables: I specifically used CREATE VIEW instead of CREATE TABLE AS to avoid loading all the data into memory. Views just store the query definition - DuckDB streams the Parquet data chunk by chunk when you actually query it. With tables, DuckDB would copy everything into its internal format, eating up memory or disk space. Zero Infrastructure: The :memory: database means no provisioning. Each Cube process gets a fresh DuckDB instance that reads directly from existing Parquet files. Direct Access: No ETL pipeline, no data loading. The read_parquet() function streams data directly from GCS, and DuckDB’s columnar engine only fetches the columns needed for each query. Wildcard Patterns: The /*.parquet pattern lets you treat multiple files as a single table without any preprocessing. Perfect for data that’s already partitioned by date or other dimensions. Performance Characteristics In this example, you could run complex queries across orders, products, line items, and inventory - joining across millions of rows within seconds. What would normally require hours of data pipeline setup can be done immediately. DuckDB’s aggressive caching means the first query might take a few seconds to fetch metadata, but subsequent queries run pretty much instantly even on larger datasets. Trade-offs Benefits: Zero data movement—queries run directly against GCS files No infrastructure to manage Instant setup for ad-hoc analysis Reuses existing Parquet files from other processes Drawbacks: :memory: means state vanishes on restart Not suitable for production workloads Performance degrades on datasets larger than available RAM Notes This pattern works for any quick data investigation. Got Parquet files? Throw them in GCS, point Cube at them with this config, and you’re analyzing data in minutes. For more permanent setups, swap :memory: for a file path to persist the DuckDB catalog between restarts. Your data lake is already an OLAP database. You just need DuckDB to unlock it. --- # Type-Safe Domain Models with Interface Augmentation in TypeScript · Aliou Diallo URL: https://aliou.me/notes/2025-08-07-type-safe-domain-models-with-interface-augmentation/ While working on @general-dexterity/cube-records, I needed a way to define1 my domain-specific Cube models in a way that would provide type-safe autocompletion. The solution was ended up pretty simple: global interface augmentation. The Problem The lib defines an empty global interface that users can augment with their own cube definitions. However, after publishing, I discovered that tsup was optimizing the empty interface into a type alias during the build: // What we write export interface CubeRecordMap {} // What tsup outputs with dts: true export type CubeRecordMap = {} // Now augmentation fails declare global { interface CubeRecordMap { // Error: can't augment a type alias users: { /* ... */ } } } The Solution Adding a dummy property prevents this optimization: export interface CubeRecordMap { __empty: { measures: {}; dimensions: {}; joins: []; }; } That __empty property isn’t arbitrary - it ensures the interface remains augmentable through the entire build pipeline2. Implementation Users can now extend the interface in their projects: declare global { interface CubeRecordMap { users: { measures: { count: { type: number } }; dimensions: { id: { type: string }; email: { type: string }; }; joins: readonly ['orders']; }; // ... other models and views } } The library extracts type-safe cube names and fields: type CubeRecordName = keyof CubeRecordMap; type CubeRecordMeasure<T extends CubeRecordName> = keyof CubeRecordMap[T]['measures'] & string; Trade-offs Benefits: Interface stays augmentable through the build pipeline Zero runtime overhead - types exist only at compile time Incremental cube definitions with immediate IDE support Drawbacks: The interface includes a dummy property Requires some documentation to explain the pattern Notes This isn’t just for my personal benefits3, this pattern provides domain-specific autocompletion without runtime cost. Instead of searching through Cube models for field names, TypeScript provides instant feedback as you type. Footnotes Or use codegen with another library. This is specifically a build tool optimization when generating declaration files, not a TypeScript language limitation. Even though it was pretty fun to try to figure this out. --- # Template Literal Types for Dynamic API Generation · Aliou Diallo URL: https://aliou.me/notes/2025-08-15-template-literal-types-for-joins/ Continuing my work on cube-records, I wanted to support joined field names like orders.total or users.email - matching exactly how the underlying Cube names them. Instead of manually defining these combinations, TypeScript’s template literal types generate them automatically. The Pattern Here’s a simplified version of the Cube schema: // User defines their schema interface Schema { users: { fields: { id: string; email: string }; joins: ['orders']; }; orders: { fields: { total: number; status: string }; joins: []; }; } Then, we use a template literal type to defined our joined fields: // Template literal magic type JoinedFields<T extends keyof Schema> = Schema[T]['joins'][number] extends infer Join ? Join extends keyof Schema ? `${Join}.${keyof Schema[Join]['fields'] & string}` : never : never; Finally, we can also use the type to create our own custom type. // TypeScript now knows these fields exist: type UserJoinedFields = JoinedFields<'users'>; // Result: 'orders.total' | 'orders.status' The infer keyword extracts each join name, then template literals compose the dot-notation paths. Usage With the schema defined above and our new type, we can define a Query type like the following: type Query<T extends keyof Schema> = { model: T; fields: (Schema[T]['fields'] & JoinedFields<T>)[]; } And in our code, we can declare a query serenely thanks to the type system preventing us from writing a query with invalid fields: const query: Query = { model: 'users', fields: [ 'email', // ✓ users field 'orders.total', // ✓ joined field 'orders.status', // ✓ joined field 'invalid.field' // ✗ Type error ] }; The real implementation has more conditionals for safety, but the core idea remains the same: extract the join names, then use template literals to compose the field paths. Trade-offs Benefits: Zero maintenance—add a field to the schema, get autocompletion everywhere Catches typos at compile time instead of runtime Scales to hundreds of field combinations without manual work Drawbacks: Type computation can slow down in massive schemas Error messages become cryptic when deeply nested Only works with predictable string patterns Notes This pattern shines when you control the schema and need to generate predictable string patterns. It’s less suitable for user-defined schemas or when you need runtime validation anyway. The sweet spot is internal APIs where you want compiler-enforced consistency between your types and runtime behavior. What you don’t write is as important as what you do. No manual string unions. No keeping field lists in sync. Add a new join relationship, and TypeScript immediately knows about every possible field combination. --- # A global .gitignore · Aliou Diallo URL: https://aliou.me/posts/a-global-gitignore/ A small tip that I’ve come across recently: It is possible to have a global .gitignore file that applies to every Git repository on your machine. Start by making a .gitignore file in your home directory, with the files you want to ignore, and place in your home directory: # ~/.gitignore .vimrc.local *.swp .idea .DS_Store Then, tell Git to use this file as global .gitignore by running in your shell: git config --global core.excludesfile ~/.gitignore You can also take inspiration from my own global .gitignore file. Enjoy! --- # Namespaced Rails validators · Aliou Diallo URL: https://aliou.me/posts/namespaced-rails-validators/ While going source spelunking, I came across this piece of code in Rails’ ActiveModel: key = "#{key.to_s.camelize}Validator" begin validator = key.include?("::".freeze) ? key.constantize : const_get(key) rescue NameError raise ArgumentError, "Unknown validator: '#{key}'" end active_model/validations/validates.rb{:target=“_blank”} {: .ma0} This means that you can namespace your custom validators: # lib/internal/email_validator.rb module Internal class EmailValidator def validate_each(record, attribute, value) return if value.ends_with?('@private_domain.com') record.errors.add(attribute, 'not from private domain') end end end And then use them like this: # app/models/admin.rb class Admin < ApplicationRecord validates :email, 'internal/email': true end Thanks to Bachir Çaoui for reviewing a draft version of this post. --- # Postgres timestamp ranges in Ecto · Aliou Diallo URL: https://aliou.me/posts/postgres-tsranges-in-ecto/ I recently read a post on Postgres’s range types{target=’_blank’} and have been trying to take advantage of them in my code. However, because some of these types aren’t shared between the different SQL databases, most Object Relation Mapping like Ruby on Rails’s ActiveRecord and database wrappers (e.g. Elixir’s Ecto{target=’_blank’}) don’t support them. Thankfully, Ecto allows us to define our custom types that can represent an unknown database type. We’ll now try to implement one to represent timestamp ranges. Let’s say we need to schedule chores between different members of a team in a spaceship. 1 The simplest way to do this would be to store the range of our chore and who is assigned to it. With Ecto, the migration creating this table would look like this: create table(:chores) do add(:user_id, references("users"), null: false) add(:note, :string) add(:range, :tsrange, null: false) timestamps(default: fragment("NOW()")) end We also need to make sure a user can’t have multiple chores overlapping with each other. For this we’ll add an exclusion constraint{target=’_blank’} on our range: # Add the btree_gist extension to allow using `gist` indexes # with scalar types, in our case the `user_id`. execute("CREATE EXTENSION btree_gist") create( constraint( "chores", :no_overlaping_chores_for_user, exclude: ~s|gist (user_id with =, range with &&)| ) ) Creating the schema We now create our schema representing a chore in the application. Let’s try to use the :tsrange as the type of our chore range: defmodule Chore do use Ecto.Schema schema "chores" do field(:note, :string) field(:range, :tsrange) belongs_to(:user, User) timestamps() end def changeset(chore, attrs) do chore |> cast(attrs, [:user_id, :note, :range]) |> validate_required([:user_id, :range]) end end When compiling this, we have an error: == Compilation error in file lib/chore.ex == ** (ArgumentError) invalid or unknown type :tsrange for field :range Because :tsrange is not a type known by Ecto, we will need to create our own type adopting the Ecto.Type behaviour{:target=“_blank”}. But first we’ll create a struct that represents a timestamp range. Representing our Range We define our Timestamp.Range as a struct with the first and last elements of the range and with options for the inclusivity of those elements in the range. defmodule Timestamp.Range do defstruct [:first, :last, opts: []] @type t :: %__MODULE__{ first: NaiveDateTime.t(), last: NaiveDateTime.t(), opts: [ lower_inclusive: boolean(), upper_inclusive: boolean() ] } end We also define a convenience function to create a Timestamp.Range: @default_opts [lower_inclusive: true, upper_inclusive: false] @spec new(NaiveDateTime.t(), NaiveDateTime.t(), Keyword.t()) :: t def new(first, last, opts \\ []) do opts = Keyword.merge(@default_opts, opts) %__MODULE__{ first: first, last: last, opts: opts } end We can now represent a Postgres’s tsrange in Elixir. Implementing the Ecto.Type behaviour The Ecto.Type behaviour expects four functions to be defined: type/0: The underlying type of our custom type, known by either Ecto or Postgrex cast/1: A function to transform anything into our custom type. load/1: A function to transform something from the database into our custom type. dump/1: A function to transform our custom type into something understood by the database. The type implementation: def type, do: :tsrange The cast implementation: we only allow our custom type to be cast: def cast(%Timestamp.Range{} = range), do: {:ok, range} def cast(_), do: :error The load implementation receives a Postgrex.Range and transforms it to a Timestamp.Range: def load(%Postgrex.Range{} = range) do {:ok, Timestamp.Range.new( range.lower, range.upper, lower_inclusive: range.lower_inclusive, upper_inclusive: range.upper_inclusive )} end def load(_), do: :error And finally, the dump implementation takes a Timestamp.Range and transforms it to a Postgrex.Range: def dump(%Timestamp.Range{} = range) do [lower_inclusive: lower_inclusive, upper_inclusive: upper_inclusive] = range.opts {:ok, %Postgrex.Range{ lower: range.first, upper: range.last, lower_inclusive: lower_inclusive, upper_inclusive: upper_inclusive }} end def dump(_), do: :error Using our new type in the schema Now that we have our custom Ecto type, we can use it in our schema: schema "chores" do field(:note, :string) field(:range, Timestamp.Range) belongs_to(:user, User) timestamps() end And we can insert new chores into the table: iex(1)> range_start = ~N[2018-09-17 10:00:00] iex(2)> range_end = ~N[2018-09-17 12:00:00] iex(3)> attrs = %{user_id: 1, range: Timestamp.Range.new(range_start, range_end)} iex(4)> Chore.changeset(%Chore{}, attrs) |> Repo.insert! %Radch.Chore{ __meta__: #Ecto.Schema.Metadata<:loaded, "chores">, id: 1, note: nil, range: #Timestamp.Range<~N[2018-09-17 10:00:00], ~N[2018-09-17 12:00:00]>, user_id: 1 updated_at: ~N[2018-09-17 16:30:05], inserted_at: ~N[2018-09-17 16:30:05], } The code examples in this post are also available on GitHub{:target=“blank”}. Thanks to Bachir Çaoui for reviewing a draft version of this post. Further reading Documentation on the Ecto.Type behaviour{:target=“_blank”} Documentation on Postgres’ range types{:target=“_blank”} More reading on Postgres’ range types{:target=“_blank”} Footnotes If you know me this might be familiar{:target=“_blank”}. --- # Three ways to ignore files in Git · Aliou Diallo URL: https://aliou.me/posts/three-ways-to-ignore-files-in-git/ TIL I learned that there are different ways to ignore files in Git: 1. Using a .gitignore file in a repository When created in a Git repository, this .gitignore is only applied to the directory it is in and its children. This means that you can ignore files in the whole repository and also ignore some files in some subdirectories. Start by creating a .gitignore in a subdirectory: # lib/.gitignore *.md With the following directory structure: . ├── lib │ ├── .gitignore │ ├── todo.md # <- Will be ignored └── Readme.md # <- Will not be ignored This file should be version-controlled and includes files that all developers working on the repository will want to ignore. 2. Using the local exclusion file .git/info/exclude Start by creating the info directory and the exclude file in our repository .git directory: mkdir -p .git/info touch exclude Then you can add files or pattern of files you want to ignore: TODO.md NOTES.txt I mainly use it to ignore files that do not need to be shared with other developers. I usually leave notes a list of TODOs at the root of a project and ignore them in this file. 3. Using a global .gitignore Start by making a .gitignore file in your home directory, with the files you want to ignore, and place in your home directory: # ~/.gitignore .vimrc.local *.swp .idea .DS_Store Then, tell Git to use this file as global .gitignore by running in your shell: git config --global core.excludesfile ~/.gitignore I use it to ignore file I never want to be committed, e.g. backup or temporary files, build artifacts, etc. Further reading Documentation for gitignore{:target=“_blank”}. --- # Using Rails's Attributes API to serialize Value Objects · Aliou Diallo URL: https://aliou.me/posts/attributes-api-and-value-objects/ Continuing my deep dive into Rails features 1, I recently read about the Attributes API{:target=“_blank”} and more particularly about how it can be used with custom types. The attribute API The attributes method allows you to define an attribute with a type on a model. This type can be a completely custom. In our example, we have spaceships that are defined by their category. Those categories are both immutable and interchangeable, and are good candidates to be transformed into value objects{:target=“_blank”}. Our current model looks like this: # app/models/ship.rb class Ship < ApplicationRecord validates :name, presence: true validates :category, presence: true validates :category, inclusion: { in: Ship::Category::VALID_CATEGORIES } end And our Ship::Category value type looks like this: # app/models/ship/category.rb class Ship::Category VALID_CATEGORIES = %w( shuttle supply_carrier troop_carrier war_ship ).freeze def initialize(category) raise "invalid category '#{category.inspect}'" unless category.in?(VALID_CATEGORIES) @raw_category = category end def to_s raw_category end # Conform to comparable. def <=>(other) VALID_CATEGORIES.index(to_s) <=> VALID_CATEGORIES.index(other.to_s) end private attr_reader :raw_category end Now, let’s update our model to retrieve our value object: # app/models/ship.rb class Ship < ApplicationRecord # ... def category @category ||= Ship::Category.new(self[:category]) end end While this does what we want, this can be improved by creating a custom type. Creating a custom type We create our custom type by inheriting from ActiveRecord::Type::Value and overriding the necessary methods: type is the type of our object when saved in the database. In our case this will be :string. cast is the method called by ActiveRecord when setting the attribute in the model. In our case, we will instantiate our value object. deserialize converts the value from the database to our value object. By default it calls cast. serialize converts our value object to a type that the database understands. In our case, we’ll send back the string containing the raw category. For our type it looks like this: # app/types/ship_category.rb class CategoryType < ActiveRecord::Type::Value def type :string end def cast(value) Ship::Category.new(value) end def deserialize(value) cast(value) end def serialize(value) value.to_s end end Registering our type Now that our type is created, we need to register it so ActiveRecord knows about it: # config/initializers/types.rb ActiveRecord::Type.register(:ship_category, CategoryType) You will need to restart your Rails server or re-register your type every time you update it. Using it in our model Finally, we can use it in our model: class Ship < ApplicationRecord attribute :category, :ship_category validates :name, presence: true validates :category, presence: true end At this point, you might be wondering: Why would I do this? Personally, I feel like it is cleaner to let Rails handle the instantiation of objects instead of the usual memoization-with-instance-variables dance. Furthermore, it allows you to add additional features to your model without having pollute the model class. In our case, we allow our ships to be compared based on their categories by implementing Comparable{:target=“_blank”} in our Category. However, there are ways to make this particular use case fall down. In our example above, we limit the category to the values defined in VALID_CATEGORIES. This means that creating a row in our database with a value that isn’t valid will make our application raise when trying to instantiate the row into a Ship object: INSERT INTO ships(id, name, category, created_at, updated_at) VALUES (10, 'USS Enterprise', 'interstellar_liner', NOW(), NOW()); enterprise = Ship.find(10) # => Error: invalid category 'interstellar_liner' For the purpose of this blog post, I chose a fairly simple example to present the attributes API. To achieve a similar result, you could also define the categories as a ActiveRecord Enum, as a Postgres Enum2 or even have them in their own table and have a Postgres association between a Ship and its category, that is backed by a foreign key to achieve integrity of your data. Thanks to Bachir Çaoui and Baicheng Yu for reviewing draft versions of this post. Further reading Martin Fowler’s post on Value Objects{:target=“_blank”}. A post on using the Attributes API with JSONB{:target=“_blank”}. Footnotes See my previous post about Namespaced Rails validators. However, this would require you to change your schema format to :sql or to override Rails’s Schema dumper to handle Postgres Enums. --- # Changing perception of objects by overriding the inspect method · Aliou Diallo URL: https://aliou.me/posts/overriding-inspect/ While writing my previous post, I realized that while using the attributes API allows us to avoid making mistakes when instantiating our value objects, it might not entirely convey that there’s a limited number of objects that can be instantiated 1. To this end, I thought about a way to prevent this: let’s pretend that our value objects are constants. Generating constants First, let’s take our Ship::Category from the previous post: class Ship::Category VALID_CATEGORIES = %w( shuttle supply_carrier troop_carrier war_ship ).freeze def initialize(category) raise "invalid category: #{category.inspect}" unless category.in?(VALID_CATEGORIES) @raw_category = category end def to_s raw_category end private attr_reader :raw_category end Let’s start by defining constants for each of our values. Under the initialize method 2, let’s create the constants using const_set and a bit of metaprogramming: class Shift::Category # ... VALID_CATEGORIES.each do |raw_category| const_set(raw_category.upcase, new(raw_category)) end end Calling Ship::Category.constants show us that our constants have correctly been created: pry(main)> Ship::Category.constants # => [:WAR_SHIP, :TROOP_CARRIER, :SHUTTLE, :SUPPLY_CARRIER, :VALID_CATEGORIES] However, inspecting the constant reveals our trickery: pry(main)> Ship::Category::SHUTTLE # => #<Ship::Category:0x00007fbddc853350 @raw_category="shuttle"> So, how do we really pretend that our Ship::Category object is truly a constant ? We can do this by overriding the inspect method: Overriding inspect As we can see above, by default, inspect returns the class name, a representation of the memory address of the object and a list of instance variables of the object. In our case, we want inspect to instead display how the object should be accessed. This means making it look like the constants we’ve created above: class Shift::Category # ... def inspect "#{self.class}::#{raw_category.upcase}" end end With this, the value object is now displayed as a constant when inspecting the object or in logs: # Before we had: pry(main)> Ship::Category::SHUTTLE # => #<Ship::Category:0x00007fbddc853350 @raw_category="shuttle"> # Now we have pry(main)> Ship::Category::SHUTTLE # => Ship::Category::SHUTTLE Besides indulging in my whims, there are other interesting reasons to override inspect: This is useful to hide sensitive values like emails or encrypted passwords, as Devise does it. Since Rails 6, you can configure ActiveRecord to filter attributes from inspection. This is also done by overriding the inspect method. The IPAddr class also overrides the inspect method to display a human readable representation of the IP address. The code examples in this post are also available on GitHub{target=“_blank”}. Thanks to Bachir Çaoui and Stéphanie Chhim for reviewing a draft version of this post. Footnotes Of course, we could look at the file defining the constants, but where would be the fun in that? Because our constants are set directly in the class, the new method needs to be already defined, hence defining them under the initialize method. --- # Using named captures to extract information from ruby strings · Aliou Diallo URL: https://aliou.me/posts/regexp-named-captures/ For an internal project at work, I recently had to parse the names of Heroku review applications to retrieve some data. The application names looked like this: <project_name>-pr-<pull_request_id> At first, since each part I needed was separated by a dash, I had some code that looked like this: *project_name, _, pull_request_id = application_name.split('-') project_name = project_name.join('-') Because the project name could also have some dashes in it, I needed to rejoin it after extracting the pull request data. At first, for a prototype, this worked fine. But when this internal project transitioned into being an important part of my team’s tooling, I started looking at a better and cleaner way to achieve the same result. Since we were already validating the format of the application name with a regular expression, I figured I’d use it to also retrieve the data using named captures. Regular expressions in Ruby For a refresher on a regular expressions, I highly recommend this article1 by Dan Eden. As a reminder, there are multiple ways to create regular expressions in Ruby: Using /xxxx/ Using percent literal : %r{} Using the class initializer: Regexp#new With your newly created regular expression, there are two main ways to check if a string matches a regular expression: Calling String#match with the regular expression as argument: 'abc'.match(/a/) # => #<MatchData "a"> Calling Regexp#match on the regular expression with the string as argument: /a/.match('abc') # => #<MatchData "a"> If the String matches the regular expression, it will return a MatchData object, otherwise it will return nil. The MatchData object encapsulates the result of matching a String against a Regexp, including the different submatches. It also contains the eventual captures and named captures. Named captures Named captures allow you to describe submatches of a regular expression and then retrieve them from the resulting MatchData object. In our case, our regular expression looked like this: /.*-pr-\d+/ To use named captures, we first need to add capture them into groups to our regular expressions. Adding capture groups is as simple as wrapping them inside parentheses: /(.*)-pr-(\d+)/ Finally, name the different captures. To do this, we need to prefix the content of the capture group with its name: /(?<project_name>.*)-pr-(?<pull_request_id>\d+)/ Now that we’ve done this, we can easily retrieve the data we want from the application name using our resulting object: expression = /(?<project_name>.*)-pr-(?<pull_request_id>\d+)/ application_name = 'my_app-pr-1234' matches = expression.match(application_name) matches[:project_name] # => 'my_app' matches.named_captures # => {"project_name"=>"my_app", "pull_request_id"=>"1234"} Thanks to Bachir Çaoui, Alexis Woo and Alexis Focheux for reviewing draft versions of this post. Footnotes While the article is intended for designers and UX writers, I found that it was an excellent introduction to regular expressions for everyone. --- # Enabling font ligatures for GitHub Monaspace in Ghostty · Aliou Diallo URL: https://aliou.me/posts/enabling-font-ligatures-ghostty/ Like many others this week, I’ve been using Ghostty as my terminal emulator. While reading the option reference, I noticed that font ligatures should be enabled by default, however it didn’t seem to be the case with GitHub’s Monaspace font: It turns out that the font splits ligatures into different stylistic sets, each set providing ligatures for specific languages. In my case, to have the ligatures most common in JavaScript, I enabled the ss01 stylistic set and a few others for other character sequences: font-family = "Monaspace Neon Var" # ss01: != and === font-feature = "ss01" # ss02: <= and >= font-feature = "ss02" # ss03: -> and ~> font-feature = "ss03" # ss04: <> and </> font-feature = "ss04" # liga: //, ..., || font-feature = "liga" I then reloaded the configuration with ⌘⇧, and the ligatures were enabled: It took me a few minutes to figure it out as I didn’t know how code ligatures worked, so I hope this helps someone in the future! --- # Chaos and Order: A game against AI · Aliou Diallo URL: https://aliou.me/posts/chaos-and-order-a-game-against-ai/ In the past year, large language models (LLMs) like Claude have become a regular part of my workflow. I’ve used them for everything from rephrasing documentation drafts to debugging code and occasionally asking them to “explain this to me.” But almost all of that has been serious, structured and focused on productivity, AI as a tool to get work done faster or more efficiently.I rarely stopped to think about how these tools could be used for fun, curiosity or even play. That changed one evening thanks to two completely unrelated events: My younger sister introduced me to a game called Chaos and Order. Anthropic announced you could now embed Claude into artifacts, giving it more interactive and persistent behavior. That coincidence sparked a simple, slightly ridiculous idea: what if I could build Chaos and Order into an artifact and then play against Claude? A Simple Prompt, A Creative Spark Knowing from experience that overly ambitious prompts tend to disappoint, I kept things basic. I asked Claude to read the rules of the game and build a local multiplayer version - just something I could test and interact with. The game is simple in design but elegant in challenge: It’s played on a 6×6 grid. On each turn, a player selects either an “X” or an “O” and places it anywhere on the board. Symbols don’t belong to either player. Order’s goal is to create a line of 5 identical symbols in a row (horizontally, vertically or diagonally). Chaos’s goal is to prevent that from happening by filling the grid in a way that blocks any winning opportunity. What makes it interesting is the psychological angle: Order’s intentions are obvious, while Chaos plays reactively - always trying to read ahead and spot the trap. Order must be clever and subtle; Chaos must be sharp and vigilant. Try playing Chaos and Order here → Claude quickly understood the rules and responded with enthusiasm, asking me questions about gameplay mechanics, visual layout and user interactions. Within a few iterations, we had a working prototype - simple, functional and kind of fun. Integrating Claude as a Player With the basic game running, I wanted to take it a step further: could Claude play the game itself? I asked it to build a single-player mode where the user plays against Claude. Impressively, it not only built the mode but added thoughtful UX features like: AI game toggle: Switch between playing against Claude or another human. Thinking indicator: A short delay with a “Thinking…” message to simulate human response time. Emoji avatar: Claude appears with a 🤖 emoji, making it easy to distinguish. Adaptive logic: Claude analyzes the board, understands its role (Chaos or Order) and makes moves accordingly. Play against Claude now → Under the hood, Claude evaluates current patterns on the grid, predicts potential outcomes and chooses strategic responses. As Order, it looks to build undetected lines. As Chaos, it blocks, disrupts and redirects. It even handles tie conditions and rare edge cases where players force a draw. But what really stood out to me wasn’t the quality of the game, it was the feeling of building alongside Claude. I wasn’t just feeding it instructions. I was bouncing ideas off it, adjusting based on its suggestions and letting it lead sometimes. It felt more like a partnership than a toolchain. A Playable and Purposeful Artifact The final product was more than just a game. It was a hands-on demonstration of how AI can support curiosity, experimentation and rapid prototyping. You can play it here. There were moments where the AI misunderstood or made a less-than-optimal move but that was part of the charm. I could tweak it, iterate and learn along the way. I wasn’t aiming for perfection; I was exploring possibility. Conclusion: Work, Play and the Space In Between Working on Chaos and Order reminded me that AI doesn’t always have to be practical or productive to be valuable. It can be a sandbox for creativity, a place to tinker, learn and even just play. This project wasn’t part of a big launch or client deliverable. I wasn’t trying to make a perfect product. I was exploring. I was learning through play. And surprisingly, I learned a lot about game design, decision trees and even my own assumptions around user experience. 🧠 Fun fact: I had never made a game before. Thinking through the rules forced me to consider how people (or agents like Claude) interact with constraints, strategies and each other. That intersection of work and play - of learning through curiosity - is where I think a lot of future use cases for AI will emerge. Not just to save time, but to expand how we think, build and experiment. 🎮 Play Chaos and Order against Claude I am available for select contracting work in Q1 2026. Please reach out if you are interested. --- # Using AI for Tooling: Building Developer Tools for Cube · Aliou Diallo URL: https://aliou.me/posts/using-ai-for-tooling/ The Problem: When Great Tools Have Rough Edges Cube is a powerful open-source semantic layer that makes it easier to build analytics-heavy applications. It allows you to model your data using YAML or JavaScript, generate dynamic queries and create performant dashboards across various databases. With features tailored for building internal business intelligence tools, it’s a fantastic foundation for teams dealing with complex data structures. But like many mature tools, Cube isn’t perfect, especially from a developer experience (DX) perspective. After using it regularly, I kept bumping into two specific and persistent pain points: Type safety (or lack thereof) when working with Cube’s React integration in TypeScript projects. Debugging challenges when trying to understand which Cube queries were firing and how they behaved. These weren’t deal-breakers, but they were constant sources of friction. Enough to make me pause and think: “What if I could smooth this out myself?” As someone who has followed AI tooling closely, I realized this was a perfect opportunity to try a new workflow: letting AI help me build the tools I wished Cube had. The Solution: Let AI Build the Tools I used Claude and Claude Code to develop two internal tools: Cube Records — A type-safe and opinionated wrapper around Cube’s React hooks Cube Explorer — A Chrome extension that simplifies Cube query debugging These weren’t theoretical side projects. They became everyday parts of my workflow. And both were built with AI as a hands-on implementation partner. Tool 1: Cube Records What It Is Cube Records is a TypeScript-first wrapper around Cube’s React hooks. It turns Cube’s useCubeQuery hook into a type-safe somewhat opinionated API with an improved and IDE-friendly developer experience. Before this, every Cube query felt like a shot in the dark. No types, no IntelliSense and limited confidence, especially because Cube model definitions were changing rapidly. This fast-paced evolution came from helping an early-stage startup reach product-market fit. That kind of velocity is exciting, but it also demands better internal tools, which is exactly the kind of challenge I solve through my freelance services. The Build Process I worked with Claude to define the ideal usage pattern - what would it look like if Cube supported fully typed queries out of the box? I initially implemented a lightweight solution that gave me some autocomplete while writing Cube queries, helping catch typos early. But the returned data wasn’t typed, which only solved half the problem. I started researching how to get fully typed results, first on my own, and increasingly with Claude helping as a research assistant, not yet as a coding partner. Then Claude Code came out, and it became a game changer. It helped me reason through the nuances of type generation and interface design in real time. I wrote more about this here: TypeScript Global Interface Augmentation. // With Cube Records - Full type safety for analytics queries import { useCubeRecordQuery } from '@general-dexterity/cube-records'; // Monthly revenue by country with shipping metrics const { data, isLoading } = useCubeRecordQuery({ model: 'orders', // ✅ TypeScript validates this exists query: { measures: ['count'], // ✅ Autocomplete dimensions: ['shipping_country'], // ✅ Type-safe timeDimensions: [{ dimension: 'order_date', // ✅ Only time fields allowed granularity: 'month', dateRange: 'last 12 months' }] } }); console.log(data[0]); // TypeScript knows the exact shape: // { // count: number, // shipping_country: string, // 'order_date.month': string // } The Result End-to-end type safety for queries, dimensions and measures Full IntelliSense and autocomplete support in VSCode Type-safe return values from Cube hooks More confident refactoring and safer production code And as a bonus, it pushed to publish two npm packages: cube-records, wrapping Cube’s react hooks with types cube-codegen, a codegen tool creating the required types for cube-records using Cube’s meta endpoint. Tool 2: Cube Explorer What It Is Cube Explorer is a Chrome extension that intercepts Cube server requests, parses them and displays them in a human-readable interface. It turns a noisy browser network tab into a clearer debugging dashboard. Before, debugging Cube queries meant hunting through network logs, guessing which request matched which chart and cross-referencing IDs. Now? I open Cube Explorer and everything I need is right there. The Build Process Debugging Cube queries wasn’t something I did every day, but when it was needed, it was frustrating. Every couple of months I’d jot down ideas for a tool that would’ve made the process easier. At some point, I pasted those notes in a Claude conversation trying to figure out how to solve this. When CRXJS v2.0 was released, it felt like I finally had everything needed to turn that long discussion into a real tool. Claude helped shape the architecture, from UI layout to request interception logic. This time however, instead of reaching out to Claude to figure more abstract pieces of the code, I entirely delegated the implementation to Claude Code, only providing UX feedback while testing it on my different projects1. This was the “AI as driver, human as copilot” workflow in action. The Result Instantly see all Cube queries and their responses Easily trace which request came from which component Reduce time spent debugging by minutes or even hours per session Chrome Extension: Cube Explorer Key Insight: Redefining What “Productive AI” Means One thing stood out to me during this process: neither of these tools is customer-facing. They don’t generate revenue. They don’t ship new product features.But they do increase the speed, safety and enjoyment of building those features.That’s the kind of productivity that compounds. When your dev environment feels better, you move faster and you make fewer mistakes. The takeaway? Using AI to improve internal tools is just as powerful as using it to ship customer features. AI makes this kind of tooling dramatically easier and faster to build. Building internal tools for myself isn’t new. As a long-time neovim user, I’ve written small plugins to streamline my workflow. And over the past five years, inspired by this article on home-cooked apps), I’ve been creating iOS tools just for fun, not profit. That same mindset naturally extends to work: building quick, precise tools to be more productive or dig deeper when debugging. It’s another form of leverage - creative, technical and personal. Results & Impact Daily Use Both Cube Records and Cube Explorer are used every day by myself and the team I currently work with. They’re now part of the standard development workflow. Developer Experience Gains Fewer bugs caused by query typos Shorter feedback loops when debugging Better focus on business logic More confidence writing Cube-based features Even code agents can now write Cube queries more reliably. If they hallucinate a model, dimension or measure, the typechecker instantly flags it. Before: Vanilla Cube.js import { useCubeQuery } from '@cubejs-client/react'; // Monthly revenue analytics - no TypeScript validation const { resultSet, isLoading } = useCubeQuery({ measures: ['orders.count', 'orders.totl_amount', 'orders.sum_shiping_cost'], // Typos! dimensions: ['orders.shpping_country'], // Typos! timeDimensions: [{ dimension: 'orders.order_date', // Manual prefixing everywhere granularity: 'month', dateRange: 'last 12 months' }] }); const data = resultSet?.tablePivot() || []; console.log(data[0]); // No types - runtime shape unknown: // { // 'orders.count': any, // Actually string "42"! // 'orders.totl_amount': any, // undefined - typo! // 'orders.sum_shiping_cost': any, // undefined - typo! // 'orders.shipping_country': any, // string // 'orders.order_date.month': any // string // } After: With Cube Records import { useCubeRecordQuery } from '@general-dexterity/cube-records'; // Monthly revenue analytics with full type safety const { data, isLoading } = useCubeRecordQuery({ model: 'orders', query: { measures: ['count', 'total_amount', 'sum_shipping_cost'], // ✅ Autocomplete dimensions: ['shipping_country'], // ✅ Validated timeDimensions: [{ dimension: 'order_date', // No prefixing granularity: 'month', dateRange: 'last 12 months' }] } }); console.log(data[0]); // Fully typed at compile-time: // { // count: number, // total_amount: number, // sum_shipping_cost: number, // shipping_country: string, // 'order_date.month': string // } The Lesson: Frustrations Are Opportunities2 Every time you think: “I wish this tool did X” “This part of my stack feels clunky” …you’re identifying an opportunity for leverage. Instead of: Filing a GitHub issue and waiting Building something from scratch over weeks You can: Define the experience you want Use Claude or Claude Code to implement it Test, refine and deploy it in days, not months Do it without compromising on quality, even for internal tools Reduce long-term maintenance by iterating improvements via natural language, not refactoring alone And, hopefully, learn new things along the way3 Conclusion: The Developer’s New Superpower AI has changed what it means to be a productive developer. It’s no longer just about what you can code, but about what you can design. With the right mindset and a clear vision, you can: Identify friction in your stack Describe the ideal solution Let AI build the first version Ship better tools, faster Cube Records and Cube Explorer came from simple questions and a willingness to experiment. That mindset shift from “builder” to “designer + AI collaborator” is the real superpower. So next time something slows you down, don’t just tolerate it. Design the fix. Let AI build it. Footnotes Thankfully, the stack used for the extension is similar, if not identical, to my day to day stack so I could pretty quickly spot and solve those rabbit holes AI tools tend to fall into. This isn’t a novel idea at all (see Paul Graham’s “How to get startup ideas”) but maybe a simpler and/or more actionable way to act upon those frustrations. Like how gnarly those TypeScript declaration can get. --- # Upgrading the nvim-treesitter plugin · Aliou Diallo URL: https://aliou.me/posts/upgrading-nvim-treesitter/ Every couple of weeks, I run MiniDeps’s :DepsUpdate and cross my fingers that everything is going to be fine. Most of the time it is but not this morning1. Immediately, I got the following errors: Error detected while processing ~/.config/nvim/init.lua: E5113: Error while calling lua chunk: ~/.config/nvim/lua/_/ui/treesitter.lua:1: module 'nvim-treesitter.configs' not found: no field package.preload['nvim-treesitter.configs'] no file '/nix/store/w997z7g0ii6figi4z98id0b0yn2jzala-luajit-2.1.1741730670-env/share/lua/5.1/nvim-treesitter/configs.lua' no file '/nix/store/w997z7g0ii6figi4z98id0b0yn2jzala-luajit-2.1.1741730670-env/share/lua/5.1/nvim-treesitter/configs/init.lua' no file '/nix/store/bmdviv9ljgqb6siq2p3dsll497fjzpif-luajit2.1-fzy-1.0.3-1/share/lua/5.1/nvim-treesitter/configs.lua' no file '/nix/store/bmdviv9ljgqb6siq2p3dsll497fjzpif-luajit2.1-fzy-1.0.3-1/share/lua/5.1/nvim-treesitter/configs/init.lua' no file '/nix/store/w997z7g0ii6figi4z98id0b0yn2jzala-luajit-2.1.1741730670-env/lib/lua/5.1/nvim-treesitter/configs.so' no file '/nix/store/bmdviv9ljgqb6siq2p3dsll497fjzpif-luajit2.1-fzy-1.0.3-1/lib/lua/5.1/nvim-treesitter/configs.so' no file '/nix/store/w997z7g0ii6figi4z98id0b0yn2jzala-luajit-2.1.1741730670-env/lib/lua/5.1/nvim-treesitter.so' no file '/nix/store/bmdviv9ljgqb6siq2p3dsll497fjzpif-luajit2.1-fzy-1.0.3-1/lib/lua/5.1/nvim-treesitter.so' Investigating further, I saw that the master branch of nvim-treesitter is now frozen and they’re now working on the main branch. The new branch is a complete rewrite. The fix could have been a simple: MiniDeps.add({ source = 'nvim-treesitter/nvim-treesitter', - checkout = 'master', + checkout = 'main', hooks = { post_checkout = function() vim.cmd('TSUpdate') end }, }) However, since I was there and I figured I’ll just do the update properly. First, my configuration looked like this: local configs = require('nvim-treesitter.configs') local mdx = require('mdx') ---@diagnostic disable-next-line: missing-fields configs.setup({ auto_install = true, highlight = { enable = true }, endwise = { enable = true }, indent = { enable = true }, }) The rewrite removes the need for manual config except for customization on where the parsers are installed. To keep the behaviours I had before, in my case, the auto_install option and the highlighting, I update my config like this: First, I installed parsers for files in my dotfile on startup. Thankfully, this is a no-op when they’re already installed: local ts = require('nvim-treesitter') -- Wait at most 30 seconds to finish installation. ts.install( { 'lua', 'vim', 'vimdoc', 'query', 'markdown', 'markdown_inline', 'json', 'yaml', }, -- Do not print summary, as this will run at startup always, all the time. { summary = false } ):wait(30000) Then, I install the parser and enable highlighting and other plugin using an autocmd that triggers on FileType: ---@type fun(args: vim.api.keyset.create_autocmd.callback_args): boolean? local install_parser_and_enable_features = function(event) local lang = event.match -- Try to start the parser install for the language. local ok, task = pcall(ts.install, { lang }, { summary = true }) if not ok then return end -- Wait for the installation to finish (up to 10 seconds). task:wait(10000) -- Enable syntax highlighting for the buffer ok, _ = pcall(vim.treesitter.start, event.buf, lang) if not ok then return end -- Enable other features as needed. -- Enable indentation based on treesitter for the buffer. -- vim.bo.indentexpr = "v:lua.require'nvim-treesitter'.indentexpr()" -- Enable folding based on treesitter for the buffer. -- vim.wo.foldexpr = 'v:lua.vim.treesitter.foldexpr()' end -- Install missing parsers on file open. vim.api.nvim_create_autocmd('FileType', { group = vim.api.nvim_create_augroup('ui.treesitter', { clear = true }), pattern = { '*' }, callback = install_parser_and_enable_features }) And like that, no more errors and treesitter works as expected. I should probably read more about the reasons of the rewrite, but I’ll leave this for another Friday. Footnotes You’d think after more than 15 years, I’d understand that Friday are not the days to be nonchalant about potentially breaking things. --- # Smaller and simpler feedback loops · Aliou Diallo URL: https://aliou.me/posts/smaller-and-simpler-feedback-loops/ One thing that’s definitely changed in how I work over the past year—as I’ve used coding agents1 more and more—is how I optimize for small, simple feedback loops. At first, it was about having more tests (in particular unit tests) to validate that something works as expected. But now, these feedback loops benefit not just the agent but me as well. One example is writing React components. In the past, I would write some unit tests and Storybook stories to validate the UX. That part hasn’t changed much. But now, in addition to the stories, I also write interaction tests using Storybook’s built-in interaction testing. This serves a dual purpose: Agents no longer have to use the Playwright MCP to see and test the component, which means less tool output added to the context. We get faster feedback on whether the component behaves as expected. AMP iterating on an integration test for a totally real component definitely used somewhere in production Another example is debugging remote systems: a couple of years ago, this was tedious if I didn’t know where to look. I’d start by searching the symptoms online, then inevitably copy-paste commands from Stack Overflow and hope they’d work. Now, I still do some research online so I know where to point the agent. Then, I set up a tmux session with the agent running on one pane and SSH to the remote system on another pane. I then instruct the agent to read from and write to this pane for the investigation. In most cases, depending on the system, I also instruct the agent to not actually run the command so I can inspect it2. Claude using Tmux to debug a totally real problem on a server that is definitely remote Let the agent see if it worked. It’ll iterate instead of guessing, and you won’t be stuck figuring out which of the twelve things it tried is the one that broke. Footnotes Today, a mix between Claude and AMP. This was not always effective in the past, but agents are getting better with these kinds of instructions. --- # Your AGENTS.md can be shorter now · Aliou Diallo URL: https://aliou.me/posts/agents-md-can-be-shorter-now/ In the past few weeks, we’ve seen updates to all the major coding models in pretty quick succession. All of them made a significant jump from the previous versions, especially on software engineering benchmarks. One thing I’ve noticed from these improvements is that you no longer need to instruct them on how to work within a codebase or how to use different tools. They figure it out without using that many tokens. This means you can simplify your AGENTS.md1 and only include things that aren’t discoverable just by looking at the code. Start with the north star My process is I write my AGENTS.md first. Before writing any commands or conventions, I start by writing a few sentences about what the project is, who it’s for, and what problem it solves—basically defining a north star. The agent will implicitly refer to this every time it rereads the file. I do this because telling the agent how to do things isn’t enough. When it understands why (what the final goal is) it makes better decisions during planning and implementation. For example: if your north star says this is a solo project where you’re the only user, the agent might suggest dropping a column and rebuilding your local database. Simple, fast, gets you moving. But if you’re working on a legacy codebase that’s in production, it’ll write a proper migration with backfill logic and a rollback strategy. Same thing with scope decisions. Should you add this new dependency? On a solo project, sure. On a production app with 10k users, the agent will consider bundle size and long-term maintenance. Should you refactor this module while you’re in there? On a new project, probably yes. On a legacy system that’s working, maybe leave it alone unless it’s blocking you. # Grain Log - iOS App Film photography app to track camera settings and film rolls. Then the essentials Then I keep some of the essentials so it’s easier for the agent to do research online if it needs to, especially regarding versions of libraries. If they’re out of the ordinary or newer than the model’s cutoff date. In my case, in addition to the stack and commands, this is where I mention that I use Nix shells for development. Without that, the agent would waste time and tokens trying to figure out or even trying to install dependencies that are already there. Here’s what a minimal AGENTS.md looks like: # Grain Log - iOS App Film photography app to track camera settings and film rolls. ## Stack - Swift, SwiftUI - GRDB (chose this over SwiftData for query control) ## Development Open `GrainLog.xcodeproj` in Xcode. ## Conventions - Models: `Roll`, `RollFrame` (not RollRecord, Frame, or Exposure) - Data: local-first, only inventory synced from API Finally, I might have some more detailed documentation or tasks/SOPs (standard operating procedures) that an agent should do in a particular order but that aren’t easily automatable. For example, reminding the agent: when you use Drizzle, first update the schema, then generate the migrations. Not the other way around, which some agents like to do. Or for end-to-end tests: update snapshots only after you’ve reviewed the visual changes, not automatically on failure. That’s pretty much it. That’s the outline most of my AGENTS.md files have followed for the past couple of months, especially since the latest models came out. What to cut So if you already have an AGENTS.md, what should you cut? Pretty much everything that’s automatable: style guidelines, import orders, naming conventions, best practices that are easy to forget. As a reminder, AGENTS.md should contain context rather than enforce rules. If you want to enforce something, add linting, pre-commits, and CI checks so that it’s an effective guardrail. The agent gets blocked from committing or moving on to the next step if it hasn’t formatted, linted, or rerun the tests. Hierarchical AGENTS.md Another thing to leverage that I haven’t seen much in projects or with teams I consult with is hierarchical AGENTS.md. Most tools read AGENTS.md starting from the file they’re working on, up to the project root or even your file system root. It’s a powerful tool, but if you don’t pay attention, you can end up bloating your context. Here’s how I use that for the Grain Log monorepo: / ├── AGENTS.md # Project-wide context └── apps/ ├── grain-log-mobile/ │ └── AGENTS.md # iOS app └── inventory-api/ └── AGENTS.md # API Root /AGENTS.md: # Grain Log Film photography app to track camera settings and film rolls. ## Applications - `apps/grain-log-mobile/` - iOS app (Swift/SwiftUI) - `apps/inventory-api/` - Camera/film inventory API (Cloudflare Workers) ## Development Uses devenv.nix: ```bash devenv shell /apps/inventory-api/AGENTS.md: # Grain Log - Inventory API Camera and film stock inventory for the mobile app. ## Stack - Hono on Cloudflare Workers - Drizzle ORM + PostgreSQL ## Commands - `pnpm dev` - Dev server (port 3200) - `pnpm db:generate` - Generate migrations - `pnpm db:migrate` - Run migrations ## Conventions - Drizzle: update schema first, then generate migrations The root file has the north star and points to the different apps. Each app’s file reinforces the north star from its perspective and adds specific details. Keep each one minimal. The agent sees both files, and context adds up fast. The cost of bloat Bloated AGENTS.mdLean AGENTS.md The difference is obvious: 21.5k tokens vs 750 tokens. That’s 10% of your context window gone before you’ve even started. Since every message gets sent back and forth with the full context, this compounds throughout your session. This also means you’re sending more tokens, which is directly impacting the cost of the conversation. What comes next A lean AGENTS.md covers the basics. But what about domain-specific knowledge that only matters sometimes? That’s where Skills come in. Footnotes Everything here applies to CLAUDE.md and similar instruction files for coding agents. See agents.md for more. --- # Skills for Domain-Specific Knowledge · Aliou Diallo URL: https://aliou.me/posts/skills-for-domain-knowledge/ One thing that bothers me sometimes with coding agents is that they don’t know about iOS 26, or they “forget” about my custom Tailwind palette. And those are because of two different problems: First, cutoff dates: the models are trained up until one date, and if whatever you’re working on was released after, well, you’re out of luck. Second, project-specific knowledge: they haven’t read the Tailwind CSS files that define that palette. They could try to figure it out by looking at the code, but that would mean going through your files, searching and reading a lot of files to find how the palette is used.1 We could solve this with AGENTS.md. But as we’ve seen before, this would cost tokens and degrade the focus of the agent. Furthermore, not everything applies to every task. iOS 26 and its updates don’t matter when I’m working on a backend API. Knowing about the Tailwind palette doesn’t matter when I’m working on database migrations. What we need is context that loads only when it’s relevant to the task at hand. Introducing Skills Thankfully, there’s a solution called Skills. They recently became an open standard. They were first created by the Anthropic team for Claude, and now they’ve been adopted by most of the coding tools.2 In a few words, what they are: a directory that contains a SKILL.md file and maybe some other optional directories for scripts, reference, and assets. The SKILL.md is pretty simple. It has a frontmatter with the name of the skill and a short description of when the skill needs to be loaded. Then in the body you have the actual instructions about whatever the skill does. Those instructions can reference documentation or scripts that are shipped with the skill. Once you have configured skills for your agent, the skill names and their descriptions are included in the system prompt, usually alongside your AGENTS.md file. Then, whenever the agent has to do a task that is relevant to a skill, it will load the full content. If the skill has scripts, it will use them. If the skill has documentation, it will read the relevant parts. In my case, I have an iOS 26 skill that lists out the updates introduced by the new version. It explains what Liquid Glass is. It covers the UX updates, like tab bars that minimize on scroll. And it adds references to the official documentation from Apple. Example: iOS 26 Skill As mentioned before, the problem is that the model knows about iOS 17 and 18, the latest versions when it was trained, but not about iOS 26 that came out after. iOS 26 introduced a bunch of new features and UX changes: Liquid Glass, tab bar minimization, navigation subtitles, glass effects. It also brought improvements to Swift 6.2. Here’s what I have in the frontmatter: name: ios-26 description: >- iOS 26 / iPadOS 26 development with Xcode 26 and Swift 6.2. Use when building iOS apps targeting iOS 26. Covers Liquid Glass design system, SwiftUI APIs, navigation patterns, tab bars, search, and Swift 6.2 language features. A simple name and an accurate description for the agent to figure out when to use it. And here’s what the content looks like: # iOS 26 Development iOS 26 was released September 2025. Apple unified all platform version numbers to "26" (iOS, iPadOS, macOS, watchOS, tvOS, visionOS) for the 2025-2026 release cycle. Future versions: iOS 27, iOS 28, etc. ## Liquid Glass Design System Liquid Glass is a translucent material that reflects and refracts surroundings. Core principle: **content first**. Standard SwiftUI components automatically adopt Liquid Glass materials. ... The content covers what changed. It goes into details, has examples, and links to Apple’s documentation. I could vendor the documentation into a references directory. But to future-proof this skill, I chose to link instead. Since my agents can fetch content online, this works fine. See the difference: without the skill vs with the skill. Example: Tailwind v4 + Project Design Another example: Tailwind v4 and project-specific design. Sometimes agents forget about the newest version of Tailwind, even though it’s within their training cutoff. They might have been trained with more content from v3. So the skill starts by explaining the v4 changes: parentheses for CSS vars, renamed utilities. It also documents the custom color palette and links to the CSS file where it’s defined. And it lists the custom utility classes. Here’s the frontmatter: name: frontend-design description: >- Tailwind CSS v4 syntax and project design system. Use when writing frontend styles. Covers v4 syntax changes, custom color palette, and utility classes. And here’s part of the content: # Frontend Design This project uses Tailwind CSS v4 with a custom color palette. ## Tailwind v4 Syntax ### CSS Variables Use parentheses, not square brackets: <!-- WRONG: v3 syntax --> <div class="bg-[--background]"></div> <!-- CORRECT: v4 syntax --> <div class="bg-(--background)"></div> ## Color Palette Defined in `src/styles/global.css`. ### Semantic Colors Use these instead of raw values: | Class | Light | Dark | |------------------|----------------|----------------| | `bg-background` | Volcanic Pearl | Zinc 900 | | `text-foreground`| Zinc 900 | Volcanic Pearl | ... The skill doesn’t vendor the palette and utilities. Those files are used in the project, so the skill links to them instead. The skill explains conventions and how to work with the palette. The project files are the source of truth. If you vendored them, they could get out of sync, and the agent would make mistakes based on stale information. Unlike the iOS 26 skill, which works for any iOS 26 project, this one is project-specific. It’s not something you’d want available to all your agents system-wide. It lives inside the project repo. Depending on your tool, this goes in different locations. For Claude: .claude/skills/ └── frontend-design/ └── SKILL.md When to Use Skills vs AGENTS.md When do you decide to use a skill versus putting something in AGENTS.md? Here’s the rule of thumb: if it only matters for certain tasks, it should be a skill. If it’s something that’s true most of the time, it should be in AGENTS.md. ContextWhereProject purpose, north starAGENTS.mdStack overview, essential commandsAGENTS.mdNon-obvious conventions (always apply)AGENTS.mdDomain-specific workflows (iOS, frontend)SkillNew API versions beyond cutoffSkillProject-specific design systemsSkillTemplates, procedures for specific tasksSkill What Comes Next Now you know about skills and how to make your agent aware of specific domain knowledge. But how do you make sure it actually applies what you say? In the previous post, we talked about removing style guidelines from AGENTS.md. And skills don’t cover all those guidelines either: they just make the agent more up to date with whatever tools it’s using. So how do we make sure the agent stays on track? This is where infrastructure comes in. You set up linting, testing, and short feedback loops. That’s what we’ll cover next. Footnotes Which, in my opinion, is a waste of tokens. Including the so-called Shitty Coding Agent. --- # The Infrastructure Around Your AGENTS.md · Aliou Diallo URL: https://aliou.me/posts/infrastructure-around-your-agent/ In the previous post, we talked about loading specific domain knowledge when it is most relevant thanks to Skills. But there is something I intentionally left out: how do you actually make sure the agent is following what you have written in those Skills? One of the answers I found working for my projects and for personal and client work is not adding more instructions but setting up an infrastructure. One thing I realized is that the AGENTS.md is about 10% of the work. The ecosystem around it is the rest. A lean AGENTS.md works if you have guardrails that enforce rules and make sure the agent is following them when writing code. Deterministic tools beat prose rules This is a simple rule that has been working for both my client projects and my personal projects: deterministic tools beat prose rules. If something can be linted, do not write it in your AGENTS.md. Instead of a convention section that says “use consistent formatting,” “keep imports sorted,” “use camelCase,” and so on, configure your linter — Biome for TypeScript, Ruff for Python. Then tell your agent to run it when it is done with a piece of code or with work. You can even skip telling your agent to run the linter and simply have a formatter in place that rewrites the code the way you expect it.1 It also works with type checkers, where they immediately catch APIs that do not exist and have been hallucinated by your agent. This is what I mean by deterministic: the feedback is immediate, unambiguous, and can be automated. This way, contrary to prose rules that can feel like suggestions, these are blockers for your agent. Scripts as the Interface An additional pattern I have seen work pretty well is using scripts as an explicit interface instead or in addition to documentation. As an example, if you use Drizzle for migrations, you could have written the following in your AGENTS.md: ## Database Migrations When creating migrations: 1. Update the schema file first 2. Use snake_case for table names 3. Run the generate command with the format... 4. Name migrations using YYYYMMDD_description... Instead, you could define those scripts in the package.json: { "scripts": { "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate" } } And in your AGENTS.md: ## Database - Drizzle ORM + PostgreSQL - Scripts in package.json. Never write migrations by hand. I have noticed that agents already read the package.json file naturally, so there is no need to document how your migrations should be written or created. They can “just” call the script. Tests as the feedback loop The best thing you can give to your agent is a small and simple feedback loop. As I wrote about this a couple of weeks ago in Smaller and simpler feedback loops, it is worth repeating here. If the agent can run tests and see its results immediately, it can iterate instead of guessing or instead of you having to describe what needs to be changed. For UI components, using Storybook interaction tests works extremely well. The agent sees the test fail, reads the error, fixes the code, and rebounds. In addition, if you have it set up, it can also check what the component looks like by using Playwright via MCP or any other custom tools.2 Example setups Here is what a minimal but effective infrastructure looks like for the stacks I have worked with the most, both for client projects and personal projects: JavaScript/TypeScript: Biome for formatting + linting (one tool, one config) TypeScript strict mode (noUncheckedIndexedAccess, strictNullChecks) Vitest for unit tests Storybook interaction tests for components Python: uv for package/env management ruff for linting + formatting ty for type checking pytest for unit tests For other languages, the idea is the same: fast tools, strict settings, immediate and concise feedback. What goes where When I need to figure out where to put things, here is the template I follow. WhatWhereProject context, north starAGENTS.mdNon-obvious conventionsAGENTS.mdStyle, import order, namingLint rulesAPI shapes, data structuresType definitionsBehavior verificationTests To repeat myself, but if it is something that can be enforced, it should be enforced either via rules or via scripts. If it is more context for the agent, it should be in the AGENTS.md. The setup in action When the infrastructure is set up correctly, here is what a change made by an agent can look like: Agent makes a change and commits it Pre-commit hooks run the formatter → code is styled correctly Linter runs → catches import issues, naming violations Type checker runs → catches hallucinated APIs, missing null checks Tests run → verify behavior works Human reviews → focuses on intent, not nitpicks At each of those steps, the agent has the possibility of fixing its mistakes or the issues. It does the fix, retries it by running the test, and then it moves on. Finally, by the time you actually are reviewing, you can focus on what is important:3 the UX of the feature, the intent of the update, whether it matches the direction of the product you are working on, and so on. Footnotes The only caveat to automating formatting is that agents have a hard time with files that change right after they write them. Even though models are increasingly trained to re-read files before additional updates, they sometimes revert formatting changes, which is why linting is preferred here, at least for me. Tools like: Playwright MCP, Playwriter Chrome extension, dev-browser tool, browser tools skill. At least in my opinion. --- # Coding Agents at Team Scale · Aliou Diallo URL: https://aliou.me/posts/coding-agents-team-scale/ The previous posts in this series covered keeping your AGENTS.md lean, loading domain knowledge with Skills, and using infrastructure to enforce rules. All of those assume one thing: that someone on the team is keeping those files up to date. Unfortunately, that’s not always the case. At the pace our industry moves, and as priorities shift within a team or company, it’s easy to forget to keep those files up to date. On a recent client project, the AGENTS.md referenced a library and one of its commands that had been deprecated weeks earlier (they had moved from Kysely to Drizzle). Every day, the agent kept running kysely migrate latest instead of the new Drizzle equivalent. It would then read the package.json, notice the error, and correct itself — so developers wouldn’t see the issue immediately and never thought to update the AGENTS.md. The file wasn’t particularly long, but it was already stale. Context rots as the code and priorities change. That’s one of the problems at team scale, and this post is about the unglamorous, less talked about part that determines whether your agent setup actually works over time: maintenance. The drift problem Your AGENTS.md is probably already out of date and it’s not particularly your fault. The codebase just moved or someone renamed a package or a new service got added or the schema changed. Agents make decisions based on what they read and the first thing they read is that file. If it’s stale, the decisions are stale too and the cost compounds over weeks. Every outdated instruction is a wrong turn that humans need to correct or additional inference costs. Eventually you stop trusting the agent, you stop maintaining the file and the cycle goes on. Ownership The fix starts with someone specific owning the AGENTS.md, the same way a team would own a package in a monorepo. In most smaller teams, this can end up being the tech lead or a senior developer. This doesn’t mean nobody else can change it, it means that this person makes sure it stays current and leverages their complete vision of the project and of the company/business. Over time, this can change as the codebase and the team grows. What they do is review changes, flag drift and push back when the file gets too bloated. To enforce this, make the boundary explicit. On GitHub, the best way is a CODEOWNERS file so that changes to any AGENTS.md request a review from the right people: # CODEOWNERS **/AGENTS.md @platform-team @tech-leads .agents/skills/ @platform-team You can also add CI checks: e.g. if the package.json file changes, the nearest AGENTS.md should be reviewed or confirmed as still accurate. You can also have a check on the length of the AGENTS.md. In one of my projects, this wasn’t a hard rule, but some necessary friction so that developers would ask themselves whether what they’re adding is relevant and if it is, whether it should be split into a child file or moved into tooling1. Maintenance strategies However, ownership alone isn’t enough without processes behind it. Here are a few things that have worked for me. Hooks and reminders Set up hooks that prompt updates when key files change. They’re just nudges so that someone with enough context can actually make the changes. A CI annotation that says “key files changed, consider reviewing AGENTS.md” is sometimes more than enough: # .github/workflows/agents-md-check.yml name: Check AGENTS.md freshness on: pull_request: paths: - "package.json" - "packages/*/package.json" jobs: check-freshness: steps: - uses: actions/checkout@v4 - name: Warn if no AGENTS.md changed run: | changed_files="$(git diff --name-only origin/${{ github.base_ref }}...HEAD)" echo "$changed_files" | grep -Eq '(^|/)AGENTS\.md$' && exit 0 echo "::warning::Key files changed but no AGENTS.md was updated. Consider reviewing agent context for drift." Regular review cadence Do it weekly or every other week, but not quarterly. Things are moving way too fast for that. You can pair it with something that already happens: dependency updates, sprint planning, sprint retrospectives or internal demos. Most of the time, five minutes is enough assuming you’re doing it regularly and consistently. LLM audits This is the one that has given me the best results: using an LLM and an agentic loop to audit your own AGENTS.md files against what the code actually looks like. What is pretty great is that it checks for actual staleness concretely. It looks in the AGENTS.md and checks whether the commands mentioned there still exist. It checks if the paths are still relevant and if new packages or services that are important are mentioned. The most realistic way to do this is to run the audit on a schedule and open an issue when drift is detected. It’s a maintenance signal, and we still leave the actual update to a person. The setup is pretty simple: a GitHub Action that runs on the main branch, gathers repository facts and sends them to a model with the right prompt. Here’s one of the prompts I’ve used as a starting point. For each team, you would iterate on it: Review all AGENTS.md files in this repository for drift and bloat. Definition of "stale" (use these checks): - Commands mentioned in AGENTS.md that do not exist in package.json / Makefile / task runner configs. - File paths or package names mentioned that no longer exist (moved/renamed/deleted). - Stack/tool versions mentioned that conflict with version sources (lockfiles, .tool-versions, .nvmrc, Dockerfiles). - Missing coverage: new workspace packages or services that exist but are not referenced. For each AGENTS.md file: 1. List concrete findings (quote the exact line and explain what it conflicts with). 2. Propose a minimal fix (1-3 bullet points). Prefer deleting or moving text over adding more. 3. Enforce brevity: if a file is >50 lines, suggest what to cut or split into a child AGENTS.md. For bigger teams, allocate budget for this the same way you would for faster CI runners. You can use cheaper and/or open source models. It’s a fairly simple task, but something that LLMs excel at. What triggers drift Triggers usually come from small changes over time, not from big rewrites. It’s adding a new script, updating a package, removing a package or renaming a service. Here’s a table of what usually starts drift and which guardrail you could attach to avoid it2. What changedTypical symptomGuardrailWho updatesNew/changed scripts in package.jsonAgent suggests commands that don’t existPR rule: if scripts change, touch the nearest AGENTS.mdPackage ownerSchema change (Prisma/Drizzle/migrations)Agent generates migrations wrong, references renamed columnsCI nudge + PR checklist itemDB/service ownerNew service or package addedAgent doesn’t know it existsRoot AGENTS.md has a services list; new package means updating itRepo ownerDeprecated endpoint or behaviorAgent keeps calling the old routeDeprecation note in AGENTS.md + failing contract testAPI ownerRenamed or moved moduleAgent references old import pathsPR checklist item + optional CI grep for old pathsPackage owner The first post was about keeping instructions lean. The second was about loading context on demand. The third was about enforcement through tools. This one is about making all of that work at team scale. This has been a series about the best way of working with coding agents as I’ve seen it in the last year of working with teams trying to increase their productivity with these new tools. Every team’s setup is of course different. If you want help figuring out what works for yours, feel free to reach out or learn more about what I do. Footnotes As covered in The Infrastructure Around Your AGENTS.md. I’ll be honest: this table was extracted from coding agent sessions by an LLM, based on actual work I’ve been doing with different teams. --- # Why I Use Pi · Aliou Diallo URL: https://aliou.me/posts/why-i-use-pi/ I randomly read Mario Zechner’s article about what he learned building his own coding agent, which inspired me to try it. After a few days I made some small contributions, and within that first week I moved on from Claude Code to using Pi entirely. Pi is also one of the building blocks of OpenClaw, which you’ve probably seen going around the past few weeks. I mentioned to some friends that I had been contributing to the Pi repo and some asked why I started using it and whether they should too. Here’s why I use it, which might help answer that. A tool that works for me first One of the first reasons is that Pi is a tool that is easier to make work for me, the same way I’ve made Neovim work for me for the past 15 years.1 One thing that always bothered me with the different agents I’ve tried, whether it’s Claude Code or Amp, is that they’re either catered towards everyone or highly opinionated. That completely makes sense business-wise, but I always found myself trying to adapt to them instead of the tools adapting to the way I work. It felt somewhat backwards in a world where AI lets you create any tool you need that completely works for you. Why shouldn’t that extend to the tools you use to create those tools?2 Easily extensible Pi by itself is very tiny but very complete. It has everything needed for a coding agent and you can simply use it without any changes. On top of that, you can create extensions that register custom tools for the agent to use or custom commands for you to use. What makes this even more powerful is that Pi knows it is extensible and knows where to find its own documentation because its system prompt references it. What ends up happening is that while you’re working, you can ask the agent “this was a difficult workflow, let’s abstract this into an extension we can reuse across projects.” For example, I’ve needed multiple times to have the agent run a process in the background and continue working, whether it’s TDD with tests running on a loop while it implements features (session) or having the agent run a server and generate an OpenAPI client from it as it iterates (session, video). Before, I would just watch the agent struggle: either it wouldn’t try to start the server or it would try and stall because the server wasn’t running in the background. To avoid this, I created my own extension that runs processes in the background and that the agent knows about.3 The trade-offs With all of this comes some trade-offs. You are building your own harness, which means you cannot delegate keeping up with models and tools to someone else. With Claude Code, the team building the models is also building the harness. With Amp, the team thoroughly tests how new models can improve their product. With Pi, you figure out which model provides what you need to do your work. Pi is open source, and every time I’ve seen a bug or something that doesn’t work as expected or a small addition that could improve the tool, I open an issue or a pull request (e.g., allowing the tool to be put in the background, fixing image forwarding during streaming). My extensions are also my responsibility. They can break for different reasons, and if I want to improve them, that’s on me too. (My extensions.) Pi is open source if you want to look at it. Mario Zechner wrote about why he built it and Armin Ronacher wrote about why he uses it. Both are worth reading. Footnotes In other words, Pi is Vim. Claude Code has a plugin system and you can set up marketplaces to package different plugins. I still felt limited. I also hear Amp is working on a plugin system I haven’t tried but will probably take a look at when it comes out. This is not a novel idea; Claude Code has background bash commands as well. ---