A VOMPECCC Case Study: Spotify as Pure ICR in Emacs

A VOMPECCC Case Study: Spotify as Pure ICR in Emacs

1. About   emacs completion

vompeccc-spot-banner.jpeg

Figure 1: JPEG produced with DALL-E 3

This is the third post in a series on Emacs completion. The first post argued that Incremental Completing Read (ICR) is not merely a UI convenience but a structural property of an interface, and that Emacs is one of the few environments where completion is exposed as a programmable substrate rather than a sealed UI. The second post broke the substrate into eight packages (collectively VOMPECCC), each solving one of the six orthogonal concerns of a complete completion system.

In this post, I show, concretely, what it looks like when you build with VOMPECCC, by walking through the code of spot, a Spotify client I implemented as a pure ICR application in Emacs.

A word I'll use throughout this post to refer to the use of VOMPECCC in spot is shim, and it is worth qualifying that. The whole package is about 1,100 non-blank, non-comment lines of Lisp1. Roughly 635 of those is infrastructure any Spotify client would need regardless of its UI choices: OAuth with refresh, HTTP transport with error surfacing, a cached search layer, a currently-playing mode-line, a config surface, player-control commands, blah blah blah. The shim is the rest: 493 lines across exactly three files (spot-consult.el, spot-marginalia.el, spot-embark.el) whose entire job is to feed candidates into Consult (source), annotate them with Marginalia (source), and attach actions to them through Embark (source). When I say spot is a shim, I mean those three files, and I'm emphasizing the fact that there is relatively little code. The rest of spot is plumbing that has nothing to do with the completion substrate.

spot implements no custom UI. It has no tabulated-list buffer, no custom keymap for navigation, no rendering code. Every interaction surface; the search prompt, the candidate display, the annotations, and the action menu; is rented from the completion substrate by the 493-line shim.

This post is about the code. Instead of cataloging spot's features (I'll do that when I publish the package to Melpa), I want to show how the code actually hangs together on top of VOMPECCC, with verbatim snippets mapped onto the interaction they produce. If the previous two posts were the why and the what, this one is the how, with a working application to ground the pattern.

2. The Demonstration   consult marginalia embark

Before any code, here is the concrete task the video is solving: I am trying to find a J Dilla song whose title I can't remember; all I recall is that the word don't is somewhere in the track name. The entire post revolves around this one video, so it is worth watching before reading on. Everything that follows is a line-by-line breakdown of the code that produces what you are about to see. In the upper right hand side of my emacs (in the tab-bar), you'll see the key-bindings and, more importantly, the commands I am invoking to drive spot. (To make this clip easier to digest, you can play, pause, scrub, view in full screen, or view as "Picture in Picture" use the video controls).

Here is what happens in the clip:

  1. I invoke spot-consult-search and type j dilla. Each keystroke fires an async query against the Spotify Web API, and the result set is streamed into the minibuffer. That is Consult. In my emacs config, Vertico2 renders the candidate set vertically so the per-row metadata is legible.
  2. I use Spotify's query parameters to widen the result set per type. Spotify's search endpoint caps results per content type, so I append parameter flags (--type=track --limit=50, etc.) to ask for a fatter haul across tracks, albums, and artists. The candidates are streamed back through Consult exactly as before, just more of them.
  3. I type ,, the consult-async-split-style character, to switch from remote search to local ICR. Everything before the comma continues to be the API query; everything after is a local narrowing pattern that matches against the candidate set already in hand. No further Spotify requests are issued, and each incremental keystroke only filters the rows Consult is already holding.
  4. I type dont (no apostrophe) looking for the song. The default matching is literal, so "dont" doesn't match "Don't". Zero candidates. The corpus contains the song; my pattern just doesn't. (You thought I did this by mistake didn't you 😜? It actually highlights why fuzzy matching is so important.)
  5. I backspace and prefix the query with ~, the Orderless3 dispatcher for fuzzy matching. ~dont now matches "Don't Cry" (and others) because fuzzy matching tolerates the missing apostrophe. The search set is unchanged; I swapped matching styles without re-querying Spotify. This may sound like a small feature, but consider how much a little fuzz widens the match space of your input strings. This is espacially important in an application like Spotify where entity names can be long and difficult to remember.
  6. I append @donuts, the Orderless dispatcher for matching against the Marginalia annotation column rather than the candidate name. That narrows the surviving candidates to tracks whose annotation mentions "donuts" (i.e., tracks on Dilla's Donuts album, my personal favourite), even though the word "donuts" never appears in any track title. The song I was looking for is right there. (note my orderless-component-separator is also ",")
  7. With the track selected, I invoke Embark (embark-act) and press P to play. The P binding dispatches to spot-action--generic-play-uri, which pulls the track's URI off the candidate's multi-data property and sends a PUT to the Spotify player. The song starts playing; no further navigation required.

Three VOMPECCC packages are doing the work: Consult (the async streaming + the split-character handoff to local ICR), Marginalia (the metadata column the @ dispatcher just narrowed against), and Embark (the action menu that allows you to play the track, list the album's other tracks, or add it to a playlist). The whole rest of this post is an argument that the code required to make this happen is pleasantly concise, because none of those capabilities (asynchronous search with narrowing, metadata annotation, annotation-aware fuzzy filtering, or contextual actions) needed to be built. They already exist in the VOMPECCC framework, and spot's only job is to feed them data.

3. Anatomy of spot   structure modularity

spot is organized so that each file corresponds to one concern. This is deliberate: the architecture mirrors the modularity of VOMPECCC itself, not because I was trying to be cute (I'm cute enough 👺), but because when your substrate is modular, consuming it modularly is the lowest-friction path.

File Responsibility Substrate package LoC
spot-auth.el OAuth2 authorization + automatic token refresh timer (none) 65
spot-generic-query.el HTTP request plumbing (sync + async, error surfacing) (none) 88
spot-search.el Cached search against the Spotify API (none) 100
spot-generic-action.el Player control commands (play/pause/next/previous) (none) 51
spot-mode-line.el Currently-playing display (none) 115
spot-var.el Configuration variables (endpoints, credentials, etc.) (none) 127
spot-util.el Alist/hash-table conversions, candidate propertize (the glue) 52
spot-consult.el Seven async Consult sources + consult--multi entry Consult 194
spot-marginalia.el Annotation functions per content type Marginalia 159
spot-embark.el Keymaps and actions per content type Embark 140
spot.el spot-mode: wires registries + timers in and out (integration) 37
Total     1128


The breakdown is the whole point of the shim framing. The three substrate-facing files (194 + 159 + 140 = 493 lines) are the part that actually integrates with VOMPECCC. None of that is UI code; there is no UI code in spot. Every pixel the user sees comes from Consult, Marginalia, Embark, or whatever the user has slotted in below them.

One caveat on the 194-line figure for spot-consult.el: roughly 105 of those lines are a 7-way parallel triplet (one source definition, one history variable, and one completion function per Spotify content type), varying only in the narrow key and the :category symbol. A small macro (spot-define-consult-source) would collapse the 105 lines into 7 invocations plus a ~25-line definition, for 30-35 lines total. The honest Consult-facing line count, with redundancy factored out, is closer to 115 than 194, and the whole shim closer to 420 than 493.

The reason I didn't write this macro is because it would muddy the concrete depiction of the VOMPECCC APIs here, and honestly, I tend to avoid over-macroizing as it creates new and confusing APIs over well-established and intuitive APIs.

4. Candidates as Shared Currency   candidates

Before looking at any of the three VOMPECCC layers individually, there is one piece of code that makes the entire integration possible. It is a short function, and if you understand it, you understand how Consult, Marginalia, and Embark cooperate without knowing anything about each other.

(defun spot--propertize-items (tables)
  "Propertize a list of hash TABLES for display in completion.
Each table is expected to have `name' and `type' keys.  Names are
truncated for display per `spot-candidate-max-width'; the full
name remains accessible via `multi-data'."
  (-map
   (lambda (table)
     (propertize
      (spot--truncate-name (ht-get table 'name))
      'category (intern (ht-get table 'type))
      'multi-data table))
   tables))

Every candidate that spot hands to Consult is a string (the Spotify item's name) carrying two text properties:

  • category is one of album, artist, track, playlist, show, episode, or audiobook. Emacs's completion metadata protocol uses this property to route candidates to the right annotator and the right action keymap. Marginalia reads it to pick an annotator; Embark reads it to pick a keymap. The two packages never talk to each other, and yet they agree on every candidate's type, because both are reading the same Emacs-standard property.
  • multi-data is the raw hash table the Spotify API returned for this item: the full JSON response with every field the API exposes. Marginalia's annotator reads from it to format the margin; Embark's actions read from it to execute playback, to navigate to an album's tracks, to add to a playlist. The candidate is the full record; the name is just the visible handle. The name multi-data is spot's own designation, not a Consult or Marginalia convention (the multi- prefix is unrelated to consult--multi); any symbol would have worked. What is conventional is attaching the domain record to the candidate via propertize in the first place.

Marginalia and Embark never talk to each other. They both read the same text property on the same candidate, and that is enough.

That is the entire integration surface: One string (display name) and two props (category and metadata). Everything else (the async fetching, the narrowing, the annotation columns, the action menu) is handled by VOMPECCC, keyed on those two properties. This is a key take away for those looking to build with VOMPECCC: build your candidates like this and you will have a good time on the mountain.

This is what I meant in the first post when I called completion a substrate rather than a UI. A UI would be "here is a widget, bind data to it." A substrate is "here is a common currency (candidates with standard properties); tools that speak the currency can be mixed freely."

5. Consult: Defining the Search Surface   consult async narrowing

Consult is spot's frontdoor. It gives me three things I would otherwise have had to build from scratch: async candidate streaming, multi-source unification with narrowing keys, history, and probably other things I'm forgetting. Here is one of the seven source definitions spot uses:

(defvar spot--consult-source-track
  `(:async ,(consult--dynamic-collection
             #'spot--consult-completion-function-consult-track
             :min-input 1)
    :name "Track"
    :narrow ?t
    :category track
    :history spot--history-source-track)
  "Consult source for Spotify tracks.")

A Consult source is just a plist. The interesting keys are:

  • :async is the candidate stream. consult--dynamic-collection is the de-facto extension point third-party packages have settled on for async sources, despite the double-dash that conventionally marks it internal4. It wraps a function that takes the current minibuffer input and returns a list of candidates. Consult handles the debouncing and the "only recompute when the input changes" logic on its side; my code just has to produce candidates for a given query. :min-input 1 prevents a search on an empty query. This is the two-level async filtering that Consult is designed around: the external tool (Spotify's API, in this case) handles the expensive filtering against its own corpus, and my completion style (Orderless, if I have it) narrows the returned set locally.
  • :narrow ?t binds the narrowing key. In the video, I could have pressed t SPC when running spot-consult-search, and the session would have been scoped to tracks only, and would have avoided querying the other sources. I didn't implement narrowing; Consult did. I just declared which character maps to which source!
  • :category track is the property that will propagate onto every candidate from this source. This is the same category property that spot--propertize-items stamps on individual candidates, and it is the hinge that Marginalia and Embark both key off.
  • :history gives me free persistent search history for this source, isolated from the other sources.

The completion function itself is trivial because all the work happens in spot-search.el:

(defun spot--consult-completion-function-consult-track (query)
  "Return track candidates for QUERY."
  (spot--search-cached-and-locked query spot--mutex spot--cache)
  spot--candidates-track)

Seven of these functions exist, one per content type, all identical except for which global they return. The heavy lifting (the HTTP call, the cache, the propertization) is shared. Each source is effectively a view onto a single search result split by type.

Putting all seven sources together into one interface is also trivial:

(defvar spot--search-sources
  '(spot--consult-source-album spot--consult-source-artist
    spot--consult-source-playlist spot--consult-source-track
    spot--consult-source-show spot--consult-source-episode
    spot--consult-source-audiobook)
  "List of consult sources for Spotify search.")

;;;###autoload
(defun spot-consult-search (&optional initial)
  "Search Spotify with consult multi-source completion.
Optional INITIAL provides initial input."
  (interactive)
  (consult--multi
   spot--search-sources
   :history '(:input spot--consult-search-search-history)
   :initial initial))

This is the command you saw in the video. consult--multi takes the list of sources, unifies their candidates into a single list, and wires the narrowing keys. Seven heterogeneous content types, one prompt, one keystroke to filter to any subset, async throughout, with per-source history.

Without Consult I would need: a separate candidate display, an async debouncer, a narrowing mechanism, per-source history buffers, and some way to visually distinguish content types in a single list.

Compare this to the counterfactual. Without Consult I would need: a separate candidate display, an async debouncer, a narrowing mechanism, per-source history buffers, and some way to visually distinguish content types in a single list. And because Consult uses the standard completing-read contract, every minibuffer feature my Emacs already has (Vertico's display, Orderless's matching, Prescient's sorting) applies to spot with zero integration code.

6. Why the Cache?   async ratelimits

I have been brushing past a detail of spot-consult.el that deserves its own section, because it is the honest cost of building on an async-on-every-keystroke substrate. consult--dynamic-collection wires the completion function to the minibuffer such that it is invoked on (a debounced version of) every keystroke the user types. For spot, each invocation issues an HTTP request to Spotify's Web API, receives a mixed-type result set, splits it across the seven global candidate lists, and returns the slice relevant to the calling source. That is the hot path. And the hot path is a rate-limited network call.

Spotify's Web API is rate-limited 🙃. Exact limits are dynamic and not publicly documented in detail, but the envelope is small enough that a rapid-typing ICR session can hit it quickly. Consider the baseline: typing radiohead fires a completion call for each prefix the user's typing pauses on (Consult's consult-async-input-debounce and consult-async-input-throttle collapse runs of keystrokes into a smaller set of actually-issued calls, but realistically that still leaves several distinct prefixes per word). Now add the common real-world pattern of typing too far, backspacing a few characters, and retyping: the same query string is re-issued within the same search session. Without a cache, each repetition burns a request, but with a cache keyed on the raw query string, repeats are actually free (or at least as cheap as a cache hit):

(defun spot--search-cached (query cache)
  "Search for QUERY, using CACHE to avoid duplicate requests."
  (when (not (ht-get cache query))
    (let ((results (spot--propertize-items
                    (spot--union-search-items
                     (spot--search-items query)))))
      (ht-set cache query results)))
  (let ((results (ht-get cache query)))
    (spot--set-search-candidates results)))

The cache is a hash table from query strings to propertized candidate lists. It lives for the life of the Emacs session, so not only backspace-and-retype within one search but also the next search session that hits the same prefix is instant. The memory cost is negligible (a few hundred candidates per query, small hash tables for each) and the request-budget win is real. And if you find yourself listening to the same music over and over, then you'll have snappier results when you go down familiar paths.

Async-on-every-keystroke against a remote corpus is the feature. A query-string cache is the bill.

This is the honest consumer tax of the substrate. The first post sold you on ICR by promising that the interaction scales constantly regardless of how big the underlying corpus gets. That claim depends on async sources that fire on every keystroke against a remote corpus, and that in turn means you as package author inherit rate-limit pressure your users never see. Consult gives you the debouncer, the display, the narrowing keys, and the stale-response discarding on its side of the protocol. The cache is what you owe back on your side when your candidate source is a rate-limited network API rather than a local list, and it is exactly the kind of infrastructure that does not belong in Consult itself (because Consult has no way to know your backend is rate-limited, or which queries are equivalent enough to cache together).

7. Marginalia: Promoting Candidates into Informed Choices   marginalia

If you watch the video carefully, each track in the candidate list is followed by a horizontally aligned column of fields: #<track-number>, artist, a M:SS duration, album name, album type, release date. Each field is rendered to a fixed width in its own face, so numbers and dates and names land as visually distinct columns rather than getting mashed together with a delimiter. Small glyph prefixes (# for counts, for popularity, for followers) disambiguate otherwise bare numbers. That column is provided by Marginalia, and it comes from one function:

(defun spot--annotate-track (cand)
  "Annotate track CAND with number, artist, duration, album, type, and date.
The track number is prefixed with `#' and duration rendered as M:SS."
  (let ((data (get-text-property 0 'multi-data cand)))
    (marginalia--fields
     ((spot--format-count (ht-get data 'track_number))
      :format "#%s" :truncate 5 :face 'spot-marginalia-number)
     ((spot--annotation-field (spot--first-name (ht-get data 'artists)))
      :truncate 25 :face 'spot-marginalia-artist)
     ((spot--format-duration (ht-get data 'duration_ms))
      :truncate 7 :face 'spot-marginalia-number)
     ((spot--annotation-field (ht-get* data 'album 'name))
      :truncate 30 :face 'spot-marginalia-album)
     ((spot--annotation-field (ht-get* data 'album 'album_type))
      :truncate 8 :face 'spot-marginalia-type)
     ((spot--annotation-field (ht-get* data 'album 'release_date))
      :truncate 10 :face 'spot-marginalia-date))))

The first line is the only plumbing: (get-text-property 0 'multi-data cand) pulls the full Spotify API response off the candidate (exactly the hash table spot--propertize-items stashed earlier), and everything after it is Marginalia's own marginalia--fields macro doing the formatting. marginalia--fields handles the alignment, the per-field truncation, and the face application. The only thing my code does is declare which fields of the Spotify payload go in which columns with which faces. This is another substrate borrow hiding in plain sight: Marginalia registers the annotator and formats its output. I never wrote a single character of alignment, padding, or colourisation logic. The annotator reached into multi-data for its fields, Marginalia's macro did the cosmetic work, and Marginalia never had to know about Spotify's data model.

spot ships seven annotators. Each one is a domain-specific projection of a single Spotify response type onto a display string. Albums surface artist, release date, and track count, artists surface popularity and follower count, shows surface publisher, media type, and episode count; and all this context is really important, especially if you are 'browsing'. The annotators are independent of the search code, independent of the actions code, and independent of each other.

Registering them with Marginalia is three lines of bookkeeping:

(defvar spot--marginalia-annotator-entries
  '((album spot--annotate-album none)
    (artist spot--annotate-artist none)
    (playlist spot--annotate-playlist none)
    (track spot--annotate-track none)
    (show spot--annotate-show none)
    (episode spot--annotate-episode none)
    (audiobook spot--annotate-audiobook none))
  "List of marginalia annotator entries registered by spot.")

(defun spot--setup-marginalia ()
  "Register spot annotators with marginalia."
  (dolist (entry spot--marginalia-annotator-entries)
    (add-to-list 'marginalia-annotators entry)))

The spot--marginalia-annotator-entries list keys on the category symbol (album, artist, and so on), the very same symbols the Consult sources stamp onto their candidates. Marginalia looks up the category of the current candidate in marginalia-annotators, finds the entry, and runs the annotator. No spot code is in that path. I only had to declare the mapping.

This is where one of the most interesting benefits of the second post shows up concretely. That post mentioned that because Marginalia annotations are themselves searchable, Orderless's @ dispatcher lets you match against annotation text. spot did not ship this feature. Orderless and Marginalia did, for free, because I stamped the annotation onto the candidate in the right way.

8. Embark: The Action Layer   embark composition

The third leg of spot's tripod is Embark. In the video, pressing the Embark action key on any candidate surfaces a menu of single-letter actions appropriate to that kind of candidate: P plays it, s shows its raw data, t lists its tracks (on albums and artists), + adds it to a playlist (on tracks). Each of those actions is a one-function definition in spot-embark.el, and their binding to candidates is declarative.

The simplest action is play:

(defun spot-action--generic-play-uri (item)
  "Play the Spotify item represented by ITEM."
  (let* ((table (get-text-property 0 'multi-data item))
         (type (ht-get table 'type))
         (offset (cond
                  ((string= type "track") `(("uri" . ,(ht-get* table 'uri))))
                  ((string= type "playlist") '(("position" . 0)))
                  ((string= type "album") '(("position" . 0)))
                  ((string= type "artist") nil)))
         (context_uri (cond
                       ((string= type "track") (ht-get* table 'album 'uri))
                       ((string= type "playlist") (ht-get* table 'uri))
                       ((string= type "album") (ht-get* table 'uri))
                       ((string= type "artist") (ht-get* table 'uri))))
         ...
         (spot-request-async
          :method "PUT"
          :url spot-player-play-url ...))))

Same pattern as the annotators: (get-text-property 0 'multi-data item) pulls the full hash table off the candidate, and the rest is Spotify domain logic. Embark invokes my action with the candidate that was highlighted; my action handles the HTTP.

The keymap wiring is also just bookkeeping:

(defvar-keymap spot-embark-track-keymap
  :parent embark-general-map
  :doc "Keymap for Spotify track actions.")

;; ... one keymap per content type ...

(defvar spot--embark-keymap-entries
  '((album . spot-embark-album-keymap)
    (artist . spot-embark-artist-keymap)
    (playlist . spot-embark-playlist-keymap)
    (track . spot-embark-track-keymap)
    (show . spot-embark-show-keymap)
    (episode . spot-embark-episode-keymap)
    (audiobook . spot-embark-audiobook-keymap)
    ...))

(dolist (map (list spot-embark-artist-keymap spot-embark-album-keymap
                   spot-embark-playlist-keymap spot-embark-track-keymap
                   ...))
  (define-key map "s" #'spot-action--generic-show-data)
  (define-key map "P" #'spot-action--generic-play-uri))

(define-key spot-embark-track-keymap "+" #'spot-action--add-track-to-playlist)
(define-key spot-embark-album-keymap  "t" #'spot-action--list-album-tracks)
(define-key spot-embark-artist-keymap "t" #'spot-action--list-artist-tracks)
(define-key spot-embark-playlist-keymap "t" #'spot-action--list-playlist-tracks)

Again, the key keys off category. Embark looks up the current candidate's category in embark-keymap-alist, finds the matching keymap, opens it. Every layer of this integration is the same trick: a candidate carries a category property, and the substrate routes based on it. All three VOMPECCC packages, working on the same candidates, sharing the same category convention, never importing each other.

8.1. Composition: When an Action Opens Another Search   composition chaining

One action in particular is worth reading slowly, because it closes the loop the thought exercise in the first post opened:

(defun spot-action--list-album-tracks (item)
  "Search for tracks on the album represented by ITEM."
  (let* ((table (get-text-property 0 'multi-data item))
         (album-name (ht-get* table 'name))
         (artist-name (ht-get* (nth 0 (ht-get* table 'artists)) 'name)))
    (spot-consult-search
     (concat
      "album:" album-name
      " "
      "artist:" artist-name " -- --type=track"))))

This action runs when I am in a completion session, run Embark on an album candidate, and press t. It extracts the album name and artist from the multi-data, builds a Spotify query using Spotify's field-filter syntax (album:X artist:Y), and calls spot-consult-search again: the same entry point the user invoked initially.

Embark action on a Consult candidate launches a new Consult session, scoped to that candidate. Three lines of Lisp. The whole "chain ICRs to compose workflows" argument from Post 1, made concrete.

Nice!!! What just happened? An Embark action on a candidate produced by a Consult source launched a new Consult session, scoped to the selected candidate, in the same substrate, with the same annotators, and the same available actions. The chaining pattern from the first post ("ICR to pick a thing, which scopes the candidate set for the next ICR") is literally three lines of spot code, because the substrate composes oh so cleanly with itself.

The first post described this as the shell's git branch | fzf | xargs git checkout pattern in miniature. In spot, the pipe is embark-act, and the downstream command is another consult--multi. It is the same compositional shape; the surface it runs on is different.

9. The Integration Point: spot-mode   modularity hooks

Both registries (Marginalia's annotator alist and Embark's keymap alist) plus the two background timers (mode-line updates and access-token refresh) get installed and uninstalled in one place:

;;;###autoload
(define-minor-mode spot-mode
  "Global minor mode for the spot Spotify client.
Registers embark keymaps, marginalia annotators, starts the
mode-line update timer, and starts a periodic access-token
refresh timer when enabled.  Cleanly removes all integrations
when disabled."
  :global t
  :group 'spot
  (if spot-mode
      (progn
        (spot--setup-embark)
        (spot--setup-marginalia)
        (spot--start-update-timer)
        (spot--start-refresh-timer))
    (spot--teardown-embark)
    (spot--teardown-marginalia)
    (spot--stop-update-timer)
    (spot--stop-refresh-timer)))

This is the entire integration layer. Toggle the mode, spot's categories appear in Marginalia and Embark and the two timers begin ticking. Toggle it off, they all disappear. No global state mutation escapes the teardown path.

And by the way, a user who never installs Marginalia or Embark still gets a working spot; the setup functions no-op gracefully (all they do is add-to-list against someone else's variable), that user just doesn't get annotations or actions. The "stack what you want, subset what you don't need" property of VOMPECCC propagates through to spot as a consumer: the package is graceful under any subset of VOMPECCC.

10. The Counterfactual: What spot Would Look Like Without VOMPECCC

To see what spot isn't building, look at the negative space.

A pre-VOMPECCC Spotify client (see smudge for an example that predates the modern completion ecosystem) has to build the UI itself: a tabulated-list-mode buffer with its own keymap, its own rendering code, its own pagination, its own selection logic. That approach works and can work well. But the cost is structural: a bespoke UI is a parallel universe of interaction that does not benefit from any completion infrastructure the user has already invested in. You have to learn its bindings, and frustratingly, these don't carry over to any other Emacs tool.

The architecture was entirely reasonable when there was nothing else to build on. The point here is purely structural: once the substrate exists, reinventing the UI on top of it is a strictly larger codebase that delivers a strictly less interoperable experience. spot is about 1,100 lines of Lisp, and its interface, as we've shown, is closer to 420 lines of Lisp. A pre-substrate equivalent is many times that, and much of the delta is code implementing things (display, filtering, selection, action menus) that Consult, Marginalia, and Embark implement once, centrally, for every completion-driven command in the user's Emacs.

This is the gap the first post was pointing at when it distinguished using completion from building on completion. A package that uses completion is a consumer of completing-read. A package that builds on completion assumes the existence of a richer substrate (async sources, categorized candidates, annotator hooks, action keymaps) and contributes into that substrate rather than rebuilding around it.

11. What This Says About the Substrate   substrate platform

Three things follow.

First, the cost of building an ICR-driven app collapses once the substrate exists. spot is about 1,100 lines including OAuth, token refresh, HTTP, caching, the mode-line, and the integration glue. The three VOMPECCC files (spot-consult.el, spot-marginalia.el, spot-embark.el) are together under 500 lines, much of it boilerplate per content type. A feature-competitive pre-VOMPECCC Spotify client would easily have been several thousand lines larger.

Second, composition is the feature, not the packages. The list-album-tracks action is the most important ten lines in the repository, not because of what it does (a Spotify query), but because of what it demonstrates: an Embark action on a Consult candidate launching a new Consult session in the same substrate. Every ICR-driven package in your Emacs configuration that shares this substrate composes with every other one. embark-export on a spot result set could, in principle, produce a native mode for Spotify results, the same way it produces Dired from file candidates or wgrep from ripgrep hits. The composability is a property of the substrate, not of any individual package.

Third, the category property is doing an enormous amount of load-bearing work. Three different packages, each knowing nothing about the others, all agree on the right behavior for every candidate because they are keying off the same standardized property 'category. The "text" in the protocol is (candidate . (category . metadata)), and every tool that speaks the protocol interoperates for free.

12. Generalizing the Pattern Beyond Spotify   generalization pattern

spot is specifically a Spotify client, but nothing about the recipe it follows is Spotify-specific. Strip the domain out and what remains is a six-step shape that applies to an enormous fraction of the services and data sources you interact with daily:

  1. An API or backend that returns typed items: each item has a type discriminator and a bag of metadata.
  2. A candidate-constructor (the spot--propertize-items analogue) that turns those items into completion candidates with a category text property and a multi-data payload.
  3. A Consult source per type, async, with a narrow key, all unified under a consult--multi entry point.
  4. A Marginalia annotator per type, keyed on category, reading the multi-data payload for its domain metadata.
  5. An Embark keymap per type, keyed on category, binding single-letter actions that operate on the multi-data payload.
  6. A minor mode that installs and uninstalls the three registries together. This one can even be optional, but I recommend doing it.

Any domain that fits that shape can be built the same way. The thought exercise from the first post (which of your daily tools reduces to "pick a thing, act on it" over a typed corpus?) has a lot of concrete answers: issue trackers, cloud consoles, email, chat, package managers, news feeds, knowledge bases, code hosting. Two worked examples are enough to sketch the altitude:

  • Issue trackers. Types are issue / epic / comment / user, metadata is status / assignee / priority / labels, actions are transition / assign / comment / close.
  • Code hosting. consult-gh already does the GitHub version. Types are repo / PR / issue / branch / release / user, metadata is state / author / date / counts, actions are clone / checkout / review / merge / close.

Several domains already ship as working packages: consult-gh, consult-notes, consult-omni, consult-tramp, consult-dir, and many others. None of these packages ships a UI; they all (roughly) follow the same six-step recipe spot follows, and each one composes with every other one automatically.

The more interesting exercise is the shape of domains that don't cleanly fit. The pattern starts to strain when items aren't naturally enumerable, or when the right interaction is a canvas rather than a list (a map, a timeline, a dependency graph). Those cases need something more than ICR. What I find remarkable is how often even those interfaces still have an ICR-shaped core (pick a location on the map, pick a node on the graph, pick a frame on the timeline), which could be delegated to the substrate while the custom-UI parts focus on what genuinely needs rendering.

The concrete-enough test I apply to any new Emacs workflow I'm considering building: can I express it as a Consult source, a Marginalia annotator, and an Embark keymap? If yes, the package will be mostly a client of the VOMPECCC API. If no, the package needs custom UI, and I should be deliberate about which parts genuinely do and which parts could still be delegated. spot is the case where the answer is a clean "yes across the board", but I've found that more often than not, the answer is yes for the first draft.

13. Conclusion

This post took a working application and showed what the argument looks like when you cash it in.

If there is one thing I want a reader to take away from the series, it is the reframe. Completion is not a convenience feature you turn on and forget about. It is the primitive on which a surprising fraction of your Emacs interaction either already runs or could run, if you let it. Packages that treat it that way end up smaller, more interoperable, and more amenable to composition than packages that treat it as one feature among many. spot is one example.

The broader claim, which I will leave you with, is that "packages that do one thing" is the lazy reading of the Unix philosophy. The sharper reading is "packages that contribute into a shared substrate." Unix pipes were never interesting because each command was small; they were interesting because every command produced and consumed plain text. VOMPECCC is interesting for the same reason, with candidates-with-properties instead of plain text. spot was easy to write because the substrate is good. Many things in your Emacs configuration could be rewritten today as "ICR applications on the substrate" and would be smaller, cleaner, and more composable as a result.

When you next find yourself thinking "I wish there were a better way to browse X", ask whether it could just be a Consult source, a Marginalia annotator, and an Embark keymap. Surprisingly often, that is the entire package, and all you have to do is feed it data.

14. TLDR

spot is a Spotify client for Emacs that implements no custom UI. About 493 of its ~1,100 lines are the "shim" that feeds candidates into Consult, Marginalia, and Embark via a single text-property pattern (category plus multi-data); the remaining ~635 are plumbing any Spotify client would need regardless of UI. The six-step recipe (typed items → propertize → Consult source per type → Marginalia annotator per type → Embark keymap per type → minor mode) generalizes to issue trackers, cloud consoles, email, chat, knowledge bases, and more, many of which already ship as working packages (consult-gh, consult-notes, consult-omni). The claim the series has been building toward: when the substrate is good, ICR applications collapse to their domain logic, and "packages that contribute into a shared substrate" is the sharper reading of the Unix philosophy.

Footnotes:

1

As of the version being discussed, the eleven .el files in the repository total about 1,128 non-blank, non-comment lines. Not a large package by any measure.

2

Vertico is the vertical minibuffer UI you see in the video. It is not part of the spot package; it is a piece of my personal Emacs configuration, one of the VOMPECCC packages the user slots in underneath a consumer like spot. A different user could run spot with fido-vertical-mode, Helm, Ivy, or plain default completing-read; the candidates and their annotations would be unchanged, only the rendering would differ.

3

Orderless is the completion style that powers the ~ (fuzzy) and @ (annotation) dispatchers in the video. Like Vertico, it is configured in my personal Emacs setup, not shipped with spot. One detail worth calling out: Orderless's default annotation dispatcher is &, not @. I remap it to @ in my own config, so the @donuts you see in the video is specific to my setup; out of the box you would type &donuts to get the same behavior. The dispatcher characters are fully user-configurable, and users on an entirely different completion style (flex, substring, basic) will see different filtering behavior.

4

The double-dash convention in Elisp marks a symbol as internal to its package. consult--dynamic-collection is formally one of those. In practice it is the extension point third-party async Consult sources have all settled on, and Daniel Mendler has been careful about signalling breaking changes in the Consult changelog when its shape does shift. spot pins consult > 1.0= for this reason.