51: Build Your Own Quillin - transcript
Jessica: The QUILL Cast, episode fifty-one. I'm Jessica. Today we change seats. Last episode showed you what Quillins are. Today you build one.
Liam: I'm Liam. And we are fifty-one of fifty-four episodes into the series, with three left after this. The reason that matters is the shape of today's hour: we are taking the most theoretical tool we have covered, the Quillin manifest and the entry module, and turning it into something you can author on your own machine before the finale. By the end of this episode, you will have scaffolded a working Quillin, contributed a command, optionally bound a hotkey, and understood exactly why each line of the manifest is there.
Jessica: Quick recap of episode fifty, because today's build stands on it. A Quillin is QUILL's plug-in system, written in Python or JavaScript on a Node runtime, with a manifest.json describing what it is and what it contributes, validated against a JSON schema before anything loads. The Quillin Manager lives under Preferences, Extensions, and lists what is installed with enable and disable. The QUILL Developer Console, the QDC, lets you poke the API interactively in Python and TypeScript. Third-party Quillin installation at scale is gated until the signing and review pipeline is finished, bundled and development Quillins work today. And the standing guarantee, repeated once more, Safe Mode disables all extensions with one flag, the escape hatch is always one restart away. All of that is the staging area for what we are about to do.
Liam: And one thing from the recap worth saying again, because it is load-bearing. Quillins run in a separate worker process. The sandbox is not a setting you forget to turn on, it is the floor plan. Whatever you build today cannot take down your editor, no matter what you get wrong. That is the trust contract that lets us hand you a workbench.
Jessica: Today's frame, in one sentence. A Quillin is the smallest thing QUILL trusts you to extend it with, and the smallest thing is not very big. We are going to build two of them, a Layer 1 snippet Quillin and a Layer 2 handler Quillin, and we are going to do it against the real bundled code in quill/quillins_bundled, so nothing is hand-waved.
Liam: What Layer 1 and Layer 2 mean, in QUILL's own terms. Layer 1 is a snippet-only extension. The manifest declares a command whose run field is a literal string, possibly with placeholders, and QUILL inserts or replaces the selection when the command fires. No code runs. No Python module, no Node module, no entry point at all. The manifest schema, quill/core/schemas/extension.json, allows the main field to be omitted for snippet-only extensions, and the validator respects that. This is the safe, tiny, declarative rung of the ladder.
Jessica: Layer 2 is a handler extension. The manifest declares a command whose run field names a handler function, and that function is registered by the entry module. The entry module is either a Python file or a JavaScript file. QUILL loads it inside the sandboxed worker process, calls register with a small API object, and the handler runs only when a user actually invokes the command, from a menu, a context menu, a hotkey, the command palette, or a smart trigger. Nothing else ever calls your code.
Liam: The capability list is the contract. Every capability your handler wants must be declared in the manifest, and the host will only let your code do what the manifest says. We saw this in action last episode with markdown-helpers, which asks for editor.read, editor.write, ui.announce, and ui.command, and never touches the filesystem, the network, or the clipboard, so it triggers no consent prompt. That is the model. Ask for what you need, no more, and the host can prove you only did what you asked for.
Jessica: A short do this now beat before we touch any code. If you have QUILL running, open the Quillin Manager so you can see the names we are about to discuss in the live list. Preferences, Extensions, Quillin Manager. Do not change anything yet. Just read the rows. Note which Quillins are enabled, which version each one is at, and which capabilities the rows show. We will come back to this screen twice more in the episode. Pause the audio, look, then come back. The seeing is part of the learning.
Liam: Back? Good. Now the build. We are going to scaffold a real Quillin on disk using QUILL's own scaffolder, and then we are going to read the produced files out loud so you understand every line.
Jessica: Open a terminal. Run python -m quill.tools.quillin_new, with a reverse-DNS id and a display name. The id must follow the pattern in the schema, lowercase letters and digits, with single dots, dashes, or underscores as separators, three to one hundred twenty-eight characters. The name is the human-readable label, one to eighty characters. By default, the scaffolder produces a Layer 2 Python extension. Pass --layer1 to scaffold a snippet-only one, pass --status-bar to include a sample status bar cell contribution, pass --doc-events to include a sample document event subscription, and pass --categories with a comma-separated list of labels from the schema's category enum. The tool writes a directory with four files: manifest.json, extension.py, README.md, and LICENSE. That is the floor plan. Today we are going to focus on the manifest and the entry module.
Liam: The manifest is validated against quill/core/schemas/extension.json. We read that schema to ground this episode, and the four required fields at the top are schema, id, name, and version. The schema field is the constant string quill.extension/1. The id field is the reverse-DNS unique id, with the pattern we just described. The name field is the display string, one to eighty characters. The version field is a semantic version, MAJOR.MINOR.PATCH, three numbers separated by dots, and the schema rejects anything else. Without these four, validation fails, and the Quillin never loads.
Jessica: The next cluster is identity and metadata, all optional. author, up to one hundred twenty characters. description, up to four hundred, the one-liner the Quillin Manager shows in the row. license, up to sixty-four, the license string in the manifest. min_quill_version, a MAJOR.MINOR.PATCH that declares the lowest QUILL build that supports this extension. If you write a Quillin that depends on a feature shipped in 1.1.0, set min_quill_version to 1.1.0, and older builds will refuse to load it. That is the floor under your code.
Liam: Then capabilities, an array of strings from a fixed enum, no duplicates. Twenty-one in total as the schema currently defines them: editor.read, editor.write, ui.announce, ui.command, ui.prompt, fs.read, fs.write, net, clipboard.read, clipboard.write, ui.status, ui.choices, storage, settings.own.read, settings.own.write, settings.core.read, settings.core.write, document.directives, document.events, schedule, and ui.log. The categories field is a separate array from a different enum, and the labels are the ones the Quillin Manager filters by: writing, accessibility, braille, productivity, developer, formatting, navigation, ai, integration, education, and utilities. Eleven in total. Pick the ones that match the work the Quillin actually does.
Jessica: Two more optional fields worth knowing about. requires is an inter-Quillin dependency list. Each entry has a required id and an optional min_version, and QUILL verifies each dependency is installed and enabled before loading this Quillin. So you can build a Quillin that assumes another Quillin is present, and the host will refuse to load it if the assumption is false. The other is net_allowed_hosts, which restricts the net capability to named hostnames with optional wildcards. When empty, all outbound hosts are permitted with user consent. When set, the host narrows the consent conversation to exactly the names you declared. That is how a Quillin that needs a single API endpoint can ask for less.
Liam: Now the contribution surface, the part that makes a Quillin feel like part of QUILL. Inside contributes, the schema allows a fixed set of top-level keys, and we will walk them in the order a new author meets them. The first is commands, an array of command objects. Each command has a required id and a required title, and the id must start with ext. followed by a lowercase token, namespaced under ext. so it cannot collide with a built-in command id. The run field is the heart of the command, and exactly one of two shapes is allowed. Either run.snippet is a literal string with placeholders, or run.handler is a string naming a function registered by the entry module. No mixing. The schema's allOf block enforces an extra rule worth saying out loud, that if any command's run is a handler, the manifest must declare a main entry module. You cannot ship a handler command without an entry point to register it.
Jessica: The snippet placeholders, exactly as the schema describes them, are these. dollar-sign curly selection, dollar-sign curly clipboard, dollar-sign curly date, dollar-sign curly time, dollar-sign curly filename, dollar-sign curly title, dollar-sign curly line_number, dollar-sign curly word_at_cursor, dollar-sign curly uuid, and also dollar-sign curly cursor for the abbreviations contribution. There is no code execution in a snippet. If you need logic, you need a handler.
Liam: Menus is the next contribution. Each entry has a required parent and a required command. The parent is a top-level menu the host builds and exposes to Quillins, and the schema's enum lists them: File, Edit, Insert, Format, Tools, Navigate, Search, View, Help, and a conventional submenu parent called Date and Time. The command field is either a contributed command id from this same manifest or a built-in command id. So a Quillin can place its commands in any of the standard menus without owning the menu itself.
Jessica: Context menu is similar, with a required command and an optional when. The when enum is small and well-defined: always, editor.hasSelection, editor.hasText, editor.empty. Use editor.hasSelection to gate an action that needs text selected, like wrapping a passage in bold. Use editor.empty to gate an action that should only run on a blank document, like inserting a template at the start.
Liam: Hotkeys is the binding contribution. Each entry has a required command and a required binding. The binding is a string in the QUILL binding grammar, and the schema comment notes that it supports the QUILL Key chord prefix. The hotkey system is conflict-aware, and the design rule is that conflicts are rejected, never silently overridden. If you bind Ctrl+Shift+B and a built-in already owns it, your Quillin does not win. The host surfaces the conflict, you pick a different chord, you ship again.
Jessica: Three more contribution keys before we look at code. Abbreviations contribute to the Insert Automation registry from episode fifteen. Each entry has a required trigger and a required description, and exactly one of expansion or handler. If you provide a literal expansion, the schema accepts dollar-sign curly cursor, dollar-sign curly date, dollar-sign curly time, dollar-sign curly clipboard as placeholders. If you provide a handler, the handler name is registered by the entry module the same way command handlers are. The remaining fields are category, enabled_by_default, case_sensitive, and file_extensions, and the comment is clear: user-defined abbreviations always take priority over contributed ones. Your typing wins, even if the Quillin would have expanded something different.
Liam: Smart triggers are the typed equals-prefix system from episode fifteen. Each entry requires trigger, command, syntax, and description. The trigger pattern is a lowercase token, letters, digits, dot, dash, or underscore, and it must not include the equals sign. The syntax field is a human-readable example like equals rand open paren paragraphs comma lines close paren. The command is either a contributed command id or a built-in id. You can declare min_args, max_args, and a large_insert_threshold, and file_extensions lets a trigger be scoped to certain file types. Smart triggers are how the equals bug, equals meeting, equals todo you may have typed in this very editor actually got into the typing model, declared in a manifest.
Jessica: Document events are the subscription model. Each entry requires event, handler, title, and description. The event enum is the lifecycle list: document.opened, document.activated, document.before_save, document.after_save, document.before_close, document.after_close, document.created, document.loaded_from_session, smart_trigger.entered, abbreviation.expanded, quillin.enabled, quillin.disabled, quill.shutdown, and settings.changed. The handler name is the Python function in main that handles the event. Optional conditions let you filter on file_extension, file_path_pattern as a glob, or content_pattern as a regex against the first four kilobytes. Requires the document.events capability and a main module. Subscribe to what you actually need. The rest stays quiet.
Liam: The remaining contribution keys are more specialized. Preferences lets a Quillin contribute an entire page of settings, declared as data, not as wx widgets. The host renders every control using accessible stock widgets and Quillins declare structure as data and never instantiate wx widgets directly. That is a sharp line, and it is how we keep preferences accessible by construction. Status bar is a cell contribution, requires ui.status and a main module, and each cell has an id, label, handler name, optional tooltip, and a width hint between one and forty. Schedule is a background timer contribution, requires the schedule capability and a main module, and each timer has an id, interval_seconds between sixty and eighty-six thousand four hundred, a handler name, and a description. You cannot tick faster than once a minute, and you cannot sleep longer than a day.
Jessica: Three specialized contribution keys round it out. file_types is a file-type handler, runs a Python handler when a document with a matching extension opens, requires document.events and a main module. snippet_gallery is a named, parameterized template contributed to the Snippet Gallery, pure text expansion, no code, no capability required. And transcription_providers is the host-mediated cloud transcription system, where the Quillin declares the provider and the host performs the upload via a named kind adapter, so the Quillin never handles audio bytes or the API key, and the call is audited under the network-egress gate. That is the contribution surface in full.
Liam: Time to look at real code. We are going to walk two bundled Quillins end to end. First, the markdown-helpers Quillin in quill/quillins_bundled. This is a Layer 2 Python Quillin, Tier C, that contributes two commands, two menu items, one context menu entry, and one hotkey. Let us read the manifest together.
Jessica: The top of the manifest. schema is quill.extension/1. id is com.quill.bundled.markdown-helpers. name is Markdown Helpers. version is 1.0.0. author is QUILL Project. description, the row in the manager, says bundled Quillin, a Layer 1 front-matter snippet and a Layer 2 bold-selection handler, surfaced on the Format menu, the editor context menu, and a hotkey. license is MIT. min_quill_version is 1.0.0. So this Quillin is honest in its own description that it mixes a Layer 1 snippet and a Layer 2 handler in the same bundle. That is allowed, the schema does not mind.
Liam: Capabilities, four. editor.read, editor.write, ui.announce, ui.command. No filesystem, no network, no clipboard, no prompts. The entry module is extension.py. The contributes block opens with commands. First command, id ext.mdh.frontmatter, title Insert Markdown Front Matter, run is a snippet. The snippet is three dashes, newline, title colon space dollar-sign curly filename, newline, date colon space dollar-sign curly date, newline, three dashes, blank line, dollar-sign curly cursor. So invoking it inserts a complete YAML front-matter block with the file's name and today's date, and parks the cursor at the very end for the user to start typing the body. That is a one-line declarative command with no code.
Jessica: Second command, id ext.mdh.bold, title Wrap Selection in Bold, run is a handler named wrap_bold. The menus array places both commands on the Format menu, with ext.mdh.bold first and ext.mdh.frontmatter second. The context_menu has one entry, ext.mdh.bold, gated on editor.hasSelection, which means right-clicking on a selected passage shows the bold option, right-clicking on empty space does not. The hotkeys array binds Ctrl+Shift+B to ext.mdh.bold. That chord is a clean binding in QUILL, and the hotkey system will tell you if it ever collides with a built-in.
Liam: The entry module, extension.py, is short enough to read in one breath. It defines a single function register that takes the api object. Inside, it defines a closure called wrap_bold. The closure reads the selection with ctx.get_selection. If there is no selection, it announces Select some text first, then run Wrap Selection in Bold, and returns. If there is a selection, it calls ctx.replace_selection with the selection wrapped in double asterisks, the Markdown bold delimiters, and announces Wrapped the selection in Markdown bold. Then api.register_command with the string wrap_bold and the closure. You have read a complete Quillin.
Jessica: Now the second walkthrough, this one in JavaScript on the Node runtime, to show that the floor plan is the same in a different language. We are looking at the word-count-node Quillin in quill/quillins_bundled. Manifest first. schema is quill.extension/1. id is com.quill.bundled.word-count-node. name is Word Count open paren Node close paren. version is 1.0.0. author is QUILL Project. description says bundled example of a Node.js Quillin, counts words in the current selection or the full document when nothing is selected, and announces the result. license is MIT. min_quill_version is 1.1.0. runtime is the string node. That is the switch that changes the language.
Liam: Capabilities, three. editor.read, ui.announce, ui.command. No editor.write, because the word counter only reads and announces, it never modifies the document. main is extension.js. The contributes block has one command, id ext.wcn.count, title Word Count open paren Node close paren, run is a handler named wordCount. One menu entry, parent Tools, command ext.wcn.count. No context menu, no hotkey, no abbreviations, no smart triggers. The smallest useful Quillin you can ship in Node.
Jessica: The entry module, extension.js, has a self-describing docstring at the top. It explains the Quillin stdio protocol, that stdin is a JSON line with method and params, and stdout is a JSON line with result and actions. The file embeds a small runtime shim that creates a context from the params, with methods getSelection, getText, replaceSelection, announce, setStatus, and getActions. Then a runHandler function that reads stdin, parses the JSON, looks up the handler, calls it with the context, and writes a JSON result line to stdout. The real handlers are below, and there is one, wordCount. It reads the selection first, then the full text, trims, splits on whitespace, and announces the count. The published Quillin would depend on the at-quill-slash-api npm package instead of the inline shim, and that is the path you would take when you ship.
Liam: Honest correction time. Two things to flag. First, the word-count-node Quillin declares min_quill_version 1.1.0, while the markdown-helpers Quillin declares 1.0.0. That difference is real, it is in the code, and a Quillin author who depends on a 1.1 feature has to set the floor correctly. A previous episode described bundled Quillins as if they all targeted the same minimum version. They do not. Verify against the manifest.
Jessica: Second correction. Episode fifty described the Quillin Manager as listing what is installed with enable and disable. That is true, and we are not walking it back. But episode fifty did not call out that some bundled Quillins are contribution-only, meaning they ship no commands at all and exist solely to feed a registry, the prompt library, the sound pack, the abbreviation list, the smart trigger list, the snippet gallery. The ai-writing-prompts Quillin in quill/quillins_bundled is a real example. Its manifest declares an empty capabilities array and an empty commands array, and its purpose is to ship a prompts.json that the host loads into the Prompt Library the moment the user opens it. So a Quillin is not always a command. Sometimes it is a contribution that shows up in a menu, a library tab, a preferences page, a typing trigger, and never once runs handler code. The shape of what a Quillin can be is wider than last episode implied.
Liam: Now the layered build, with the field names from the schema as the spine. Layer 1 first. Make a directory, write a manifest.json, declare schema, id, name, version, author, description, license, min_quill_version, categories, and a contributes block whose commands array has one entry whose run is a snippet. No main, no capabilities, no runtime. Done. Drop the directory in your user extensions folder, open the Quillin Manager, refresh, enable, run the command from the command palette, hear the screen reader announce the inserted text. The whole thing is under twenty lines of JSON and no Python at all.
Jessica: Layer 2, Python. Add a main field pointing to extension.py. Add the capabilities you actually need, ask for editor.read when you read the selection, editor.write when you replace it, ui.announce when you talk to the user, ui.prompt when you ask the user a question, ui.command because you are registering a command, and so on, one capability per thing you touch. Write extension.py with a single register function that calls api.register_command with a string name and a Python function. Inside the function, use ctx.get_selection, ctx.get_text, ctx.replace_selection, ctx.insert_text, ctx.announce, ctx.prompt, exactly as the markdown-helpers and insert-character examples show. No global state at import time, no file I/O unless you asked for fs.read or fs.write, no network calls unless you asked for net and the user consented.
Liam: Layer 2, Node. Add runtime node. Add main pointing to extension.js. The schema's allOf block switches the main filename pattern to a .js suffix when runtime is node, and a .py suffix otherwise. Your module reads JSON from stdin, dispatches to the right handler, builds an actions array, writes a JSON result line to stdout. The host owns the lifecycle. The Quillin owns the logic. The protocol is the bridge.
Jessica: Three more steps turn a directory into something that feels like part of QUILL. One, add menus entries for every command you want surfaced, parent first, command second, using the schema's enum of menu names. Two, add a context_menu entry gated on editor.hasSelection or editor.empty for actions that only make sense on a non-empty selection or a blank document. Three, add a hotkey with a binding in the QUILL binding grammar, and let the conflict checker tell you if your chord collides with a built-in. None of this is hard, all of it is in the manifest, and none of it touches Python or JavaScript.
Liam: Do this now beat number two, the build. In a scratch directory, run python -m quill.tools.quillin_new com.example.hello "Hello Quillin" --layer1. Open the produced manifest.json, read it, change the description, change the snippet body to a sign-off block you actually paste into documents. Drop the directory into your user extensions folder, which the Quillin Manager's Install From Disk dialog will tell you the path to. Refresh, enable, run the command from the command palette. That is the smallest meaningful Quillin you can ship, and you just shipped it.
Jessica: And do this now beat number three, the contrast. Run the same scaffolder without --layer1, this time for a Layer 2 Python Quillin, name it com.example.announce-selection, title Announce Selection. Open extension.py, change the handler to read the selection with ctx.get_selection, and if it is empty, announce No text selected, otherwise announce the text itself. Add the editor.read and ui.announce capabilities to the manifest. Drop it in, refresh, enable, run it. You have now built both rungs of the ladder, on the same machine, in under five minutes each. The distance from I use this editor to I extended this editor is exactly what we said in episode fifty, shorter here than in anything else we have used.
Liam: A few honest things to call out before we close. First, the npm package at-quill-slash-api is the path published Node Quillins take. Our bundled word-count-node example inlines a small runtime shim for clarity. The protocol is the same, the message shapes are the same, and the at-quill-slash-api package is a thin wrapper that hides the JSON line protocol. If you read the inlined shim, you understand the wire format. If you import at-quill-slash-api, you skip the boilerplate. Both are valid. Pick the one that matches your taste.
Jessica: Second, the Quillin Manager install path is local. As we said in episode fifty, third-party Quillin installation at large is gated, and the marketplace is not open. The path that works today is Install From Disk in the manager, and a development Quillin sitting in your user extensions folder, which is under your home directory. That is the workflow the project endorses until signing and review are ready. Build, share the .zip or the directory with a friend, let them install from disk. No central registry, no install counters, no telemetry. Quiet by design.
Liam: Third, the linter. python -m quill.tools.quillin_lint, with a directory, and a --strict flag, validates a Quillin against the schema and the project's own rules. Run it on the directory the scaffolder produced, and you have a working test of the manifest. The linter is the same gate the project uses in its own CI, so what passes for you will pass for the project. This is the test loop. Write, lint, fix, lint, ship.
Jessica: Homework, four steps. One: scaffold a Layer 1 snippet Quillin, name it after a real thing you type more than once a week, and run it. Two: scaffold a Layer 2 Python Quillin, give it one menu entry, and run it. Three: open quill/core/schemas/extension.json and read the capabilities enum and the contributes block once, top to bottom, so the field names become vocabulary. Four: run python -m quill.tools.quillin_lint on both Quillins, with --strict, and let the linter tell you what you got right and what you got wrong. That is the build-test-share loop, in miniature.
Liam: Next episode is the finale. Episode fifty-two. We hand the keys back to the trust architecture, the safety nets, the community that built all of this, and where QUILL goes from here. Two episodes after that, episode fifty-three, and one more after that, episode fifty-four, close the series. The whole arc lands in the finale and its companions.
Jessica: The roadmap, in one beat. A Quillin is a manifest, a capability list, an entry module, and a contribution surface. Today you saw what each of those is, in code, in two languages, against the real schema. The safety model is the floor. The manifest is the contract. The entry module is the work. You do not need permission to build one.
Liam: And one more thing, because this matters. We are fifty-one of fifty-four episodes in, three more after this one. The thing you have been hearing for fifty-one episodes, screen reader first, capability before convenience, escape hatches guaranteed, consent specific, this episode was the most concrete expression of it. The manifest is the literal contract. The capabilities are the literal fence. The entry module is the literal work. Software you can audit, that audits itself, that you can extend without asking. That is the whole shape.
Jessica: I'm Jessica.
Liam: I'm Liam. Build something small, lint it, and we will see you in the finale.