Skip to content

Repository files navigation

Merlin Schema

Authoritative documentation for the Merlin command-palette / voice-assistant schema used across the HAX ecosystem.

License: Apache 2.0

What is Merlin?

Merlin is the universal command-palette and voice assistant in the HAX ecosystem. It is implemented by the <super-daemon> web component — a global singleton (globalThis.SuperDaemonManager) that surfaces a searchable palette of commands and a debounced, multi-step "program" engine. Users open it with Alt+Shift (or Meta+Shift on macOS). The persona name "Merlin" appears in voice prompts, UI copy, and throughout the codebase.

Merlin is used across HAXcms (site operations, admin panels, page editing), the HAX body (content authoring), and app-hax (the application shell). Any part of the application can register commands into the single shared palette, and those commands are filtered by the active "context" so the user only sees what is relevant.

This repository documents the schema / contract for the two data structures that plug into Merlin — the Option and the Program — so that AI agents and human contributors can register and build Merlin commands without reverse-engineering the source.

Where the runtime implementation lives

This repo is documentation only. The code that consumes this schema lives in the webcomponents monorepo:

  • haxtheweb/webcomponents → elements/super-daemon/
    • super-daemon.js — the singleton element, defineOption(), runProgram(), keyboard/voice event listeners.
    • lib/super-daemon-ui.js, lib/super-daemon-row.js, lib/super-daemon-search.js — the UI that renders Option/Program result rows.
  • haxtheweb/webcomponents → elements/haxcms-elements/lib/core/utils/*Program.js — real Program factory modules (EditTagsProgram.js, ExportSiteProgram.js, EditTitleProgram.js, etc.).
  • haxtheweb/webcomponents → elements/haxcms-elements/lib/core/haxcms-site-editor-ui.js (firstUpdated()) — the largest single registration site for Options and Programs.
  • haxtheweb/webcomponents → elements/haxcms-elements/lib/core/haxcms-cheat-codes.js — lightweight static Option examples (Easter eggs).
  • haxtheweb/webcomponents → elements/utils/lib/keyboardShortcuts.js — the shared KeyboardShortcutManager registry that the Option shortcut field resolves through (also used by HAXcms Ctrl+Shift bindings and HAXStore markdown triggers).

Files in this repo

File Purpose
merlin-option.schema.json Formal JSON Schema (draft 2020-12) for the base Option object.
merlin-program.schema.json Formal JSON Schema for the Program variant.
example-option.json Annotated real-world Option example.
example-program.json Annotated real-world Program example.
AGENTS.md Short pointer for AI agents.

Core concepts

Merlin has two kinds of registrable commands:

  1. Option — a static, searchable command. It appears in the palette list; selecting it fires a single CustomEvent and (usually) closes the palette. Registered via SuperDaemonInstance.defineOption(option).

  2. Program — a dynamic, search-driven command. Selecting its entry row launches "program mode": an async function is invoked with the user's search text, and the array of Option-shaped rows it returns replaces the top-level list. The function is re-invoked (debounced ~150ms) on every keystroke, so the results live-update as the user types. A Program is just an Option whose eventName is "super-daemon-run-program" and whose value.program is that async function.

The singleton access pattern

Merlin is a single shared instance. Get a reference to it like this:

import { SuperDaemonInstance } from "@haxtheweb/super-daemon/super-daemon.js";

// or, from anywhere without an import:
const SuperDaemonInstance =
  globalThis.SuperDaemonManager.requestAvailability();

Then register commands:

SuperDaemonInstance.defineOption({ ... });

The registration call

defineOption(option) does light validation (requires value, title, eventName) and then auto-builds a searchable index string from the option's other fields. It pushes the option into allItems. Options can be registered at any time — most are registered in an element's firstUpdated() or on module load.


Option schema reference

Required fields: title, value, eventName.

Field Type Required Default Purpose
title string yes — Primary display label. Used for alphabetical sort and exact-match priority boosting.
value object yes — Payload dispatched as the detail of the CustomEvent named by eventName. Shape varies by eventName (see Event contract).
eventName string yes — Name of the CustomEvent dispatched when the row is selected. Known values below.
icon string no — Icon name (e.g. "icons:save", "hax:wizard-hat") rendered by simple-icon-lite.
image string no — Image URL rendered in the row as an alternative to icon.
textCharacter string no — A single character or emoji rendered as an alternative to icon/image (e.g. "🍌").
tags string[] no [] Searchable keyword tags. Also rendered as simple-tag chips in the row.
path string no "" Breadcrumb-style location string (e.g. "CMS/admin/seo"). Searchable as a whole phrase and split on /.
priority number no 0 Sort weight. Lower numbers sort higher (appear nearer the top). Negative values boost prominence. An exact title match forces -10000000.
context string | string[] no "*" Visibility scope. "*" = available in every context. Other common values: "CMS", "HAX", "admin", "logged-in", ">" (developer mode).
voice string no falls back to title Voice-command phrase pattern for the hal-9000 speech layer, e.g. "(toggle) dark mode". Parenthesized segments are optional words.
more lit-html TemplateResult no — Extra detail rendered in an expandable <details> section of the row.
inline boolean no false When true, the option is eligible to appear in inline (mini) mode.
hidden boolean no — When true, the option is registered and invokable by machineName/shortcut but excluded from browse/search results.
shortcut string | object no — Keyboard-shortcut metadata linking the option to the shared KeyboardShortcutManager registry. Either a registry id string (e.g. "cms-save") or an inline descriptor object (see Shortcut metadata). Resolved to shortcutLabel; both excluded from the search index.
index string auto — AUTO-BUILT by defineOption() — do not set this yourself. A space-joined, de-duplicated, lowercased search index derived from tags/path/title/voice/more/context plus the full path phrase.
shortcutLabel string auto — AUTO-BUILT by defineOption() from shortcut — do not set this yourself. Human-readable label from KeyboardShortcutManager.getLabel() (e.g. "Ctrl⇧S" for a binding, "###" for a markdown trigger); empty string when none resolves.

Program schema reference

A Program is an Option with eventName: "super-daemon-run-program" and a value object containing:

value key Type Required Purpose
name string yes Human-readable name shown as the program-mode title while the program is active.
program async (input, values) => Promise<Option[]> yes The search function. input is the current search text; values is the bag passed into runProgram(). Each returned item is an Option-shaped row rendered by super-daemon-row. Re-invoked (debounced ~150ms) on every keystroke.
machineName string no Stable identifier enabling runProgram('like', context, values, 'machineName') to resolve and invoke this program by name without holding a direct function reference.
context string no Command context to activate when the program launches (e.g. "CMS" or ">").
placeholder string no Placeholder text for the search input while the program is active (e.g. "Enter new title").
initialValue async (values) => Promise<string> no Optional prefill. When the program is launched generically (no explicit like/search), a non-empty string return pre-fills the search input. Used by EditTagsProgram to pre-fill the active page's current tags.

The lazy factory pattern

Programs are commonly lazy-loaded via dynamic import() of a createXProgram(context) factory module, so the program code is only fetched when the user actually selects it:

SuperDaemonInstance.defineOption({
  title: "Update page tags",
  icon: "icons:label",
  tags: ["CMS", "edit", "tags", "metadata"],
  eventName: "super-daemon-run-program",
  path: "CMS/edit/tags",
  context: ["CMS"],
  voice: "edit tags",
  value: {
    name: "Update page tags",
    machineName: "edit-tags",
    placeholder: "Enter tags separated by commas",
    initialValue: async (values) => {
      const activeItem = toJS(store.activeItem);
      return (activeItem && activeItem.metadata && activeItem.metadata.tags) || "";
    },
    program: async (input, values) => {
      const { createEditTagsProgram } = await import(
        "./utils/EditTagsProgram.js"
      );
      return createEditTagsProgram(this)(input, values);
    },
  },
});

The factory module exports a createXProgram(context) function that returns the actual async (input, values) => [...] function, closing over context (typically the haxcms-site-editor-ui instance) so the program can call back into editor methods.

What a program returns

The program function returns an array of Option-shaped rows. Each row follows the same Option schema above and is rendered by super-daemon-row. A common pattern is to return "confirm" rows that use eventName: "super-daemon-element-method" to dispatch a save event:

return [
  {
    title: `Save tags: ${input}`,
    icon: "icons:check",
    tags: ["confirm", "save"],
    eventName: "super-daemon-element-method",
    path: "CMS/edit/tags/confirm",
    value: {
      target: globalThis,
      method: "dispatchEvent",
      args: [
        new CustomEvent("haxcms-save-node-details", {
          bubbles: true,
          composed: true,
          detail: { id: activeItem.id, operation: "setTags", tags: input },
        }),
      ],
    },
  },
];

runProgram() — invoking a program programmatically

Programs can also be launched from code (not just by user search), via SuperDaemonInstance.runProgram():

runProgram(
  like = null,      // pre-fill the search input (string or null)
  context = "/",    // command context to activate
  values = {},      // bag passed as the 2nd arg to the program function
  program = null,   // a direct function reference OR a machineName string
  name = null,      // program-mode title
  search = "",      // initial search text
  placeholder = null,
);

When program is a string, runProgram looks up the matching value.machineName in allItems to resolve the function. The waveWand(params, target, sound) helper wraps runProgram + open() + an optional sound for the common "open Merlin in mini/wand mode near a target" interaction pattern.


Event contract

When a result row is selected, super-daemon-row.selected() always dispatches a super-daemon-row-selected event (with detail = the row element), then dispatches a CustomEvent named option.eventName with detail set to option.value. For any eventName other than super-daemon-run-program, it also dispatches super-daemon-close to close the palette. Program selections keep the palette open so the user can continue interacting.

The super-daemon singleton listens for these events globally and routes them:

eventName What the runtime does with value Closes palette?
super-daemon-element-method Calls value.target[value.method](...value.args) yes
super-daemon-element-click Dispatches a synthetic MouseEvent("click") on value.target yes
super-daemon-run-program Hands value to runProgram() — enters program mode no (stays open)
disabled No-op. Used for informational rows like "No active page found" yes

Other events the singleton listens for (dispatch these to drive Merlin from code):

Event Effect
super-daemon-define-option detail is treated as an Option and registered via defineOption().
super-daemon-run-program detail ({ program, context, name, placeholder, values }) launches a program.
super-daemon-element-method detail ({ target, method, args }) invokes a method on a target.
super-daemon-element-click detail ({ target }) clicks a target.
super-daemon-close Closes the palette and resets state.
super-daemon-program-enter detail ({ programName, input }) handles Enter pressed with no filtered results (used by the create-page program).
super-daemon-voice-command detail ({ command, callback, context }) registers a voice command.

Context system

Every Option declares a context — a string or array of strings that determines when it is visible. The singleton tracks an active commandContext (a single string) and a context array (the set of contexts the current page/app state provides). An option appears only when its context intersects the active set.

Context value Meaning
"*" Global — always available (the default when context is omitted).
"CMS" Available while in the HAXcms site-editor context.
"HAX" Available while in the HAX body editor (authoring) context.
"admin" Available for admin/site-settings operations.
"logged-in" Available only when the user is authenticated.
">" Developer mode — a special context entered by typing > in the palette.

The user switches the active commandContext by typing a prefix character (/, *, or >) into the search input, or via voice ("run program" → /, "developer mode" → >). When commandContext is "*" (global browse), options are filtered against the page's context array; when it is a specific value, only options whose context includes that value are shown.


Voice command layer (optional facet)

When voiceSearch is enabled on the super-daemon element, the hal-9000 speech engine is loaded and Merlin's commands become voice-addressable. Each Option can declare a voice phrase pattern; if omitted, the title is used. The pattern syntax uses parenthesized segments for optional words, e.g. "(toggle) dark mode" matches both "toggle dark mode" and "dark mode".

Voice commands are re-processed (reprocessVoiceCommands()) whenever the visible item set changes (e.g. context switch), so only currently-relevant options are voice-addressable. A built-in catch-all ("*anything") routes unrecognized speech into the search input.

This layer is optional — Options and Programs work fully without it. The voice field is purely additive.


Shortcut metadata (optional facet)

An Option (or Program entry row) can declare a shortcut field that links it to the shared KeyboardShortcutManager registry — the single source of truth for keyboard/insertion shortcuts across the HAX ecosystem. That registry is also used by HAXcms Ctrl+Shift+[Key] bindings and the HAXStore markdown-trigger-to-gizmo map, so a Merlin option can reference the same shortcut descriptor those subsystems use.

shortcut accepts either:

  • a registry id string (e.g. "cms-save") — resolves to a descriptor previously registered via KeyboardShortcutManagerInstance.register(...), or
  • an inline descriptor object with the canonical shape:
{
  id: "cms-save",                          // stable unique id
  type: "binding",                         // 'binding' (modifier+key) | 'markdown' (typed trigger insert)
  key: "S",                                // physical key (binding)
  ctrl: true, shift: true, alt: false, meta: false, // binding modifiers
  trigger: "###",                          // markdown trigger (type: 'markdown')
  tag: "h3",                               // markdown insert target tag
  content: "",                             // markdown insert inner content
  description: "Save page",                // human-readable
  context: "edit",                         // 'edit' | 'view' | 'global'
  allowInInput: false,                     // whether the binding fires while focus is in an input
  callback: Function,                      // optional direct callback (binding)
  condition: Function,                     // optional active-condition (binding)
  eventName: "super-daemon-element-method", // optional event to fire instead of / alongside callback
}

When shortcut is present, defineOption() resolves a human-readable label via KeyboardShortcutManager.getLabel() and stores it on shortcutLabel (e.g. "Ctrl⇧S" for a binding, "###" for a markdown trigger). The UI reads shortcutLabel to render a keyboard hint in the row. Both shortcut and shortcutLabel are excluded from the search index so they don't pollute text matching.

Real example (from haxcms-site-editor-ui.js):

SuperDaemonInstance.defineOption({
  title: this.t.save,
  icon: "icons:save",
  tags: ["CMS", "save", "page"],
  shortcut: "cms-save", // links to the Ctrl+Shift+S binding
  value: { target: this, method: "_editButtonTap" },
  context: "HAX",
  eventName: "super-daemon-element-method",
  path: "CMS/action/save",
});

This layer is optional — the shortcut field is purely additive and does not change how an option is invoked; it only surfaces a keyboard-hint label in the UI and (optionally) ties the option to a shared descriptor for cross-subsystem consistency.


Minimal working sample

A static Option (fires one event and closes):

import { SuperDaemonInstance } from "@haxtheweb/super-daemon/super-daemon.js";

SuperDaemonInstance.defineOption({
  title: "Save",
  icon: "icons:save",
  tags: ["CMS", "save", "page"],
  shortcut: "cms-save", // links to the Ctrl+Shift+S binding
  context: "HAX",
  eventName: "super-daemon-element-method",
  path: "CMS/action/save",
  value: {
    target: this,
    method: "_editButtonTap",
  },
});

A dynamic Program (launches a search-driven sub-palette):

SuperDaemonInstance.defineOption({
  title: "Export this site",
  icon: "icons:file-download",
  tags: ["CMS", "export", "site"],
  eventName: "super-daemon-run-program",
  path: "CMS/export/site",
  context: ["CMS"],
  voice: "export site",
  value: {
    name: "Export this site",
    machineName: "export-site",
    program: async (input, values) => {
      const { createExportSiteProgram } = await import(
        "./utils/ExportSiteProgram.js"
      );
      return createExportSiteProgram(this)(input, values);
    },
  },
});

Full annotated sample

The following end-to-end example shows a Program (Update page tags) that pre-fills the active page's current tags, offers a save action, and offers a clear action. It is adapted from EditTagsProgram.js and the registration in haxcms-site-editor-ui.js.

1. Registration (in haxcms-site-editor-ui.js firstUpdated())

SuperDaemonInstance.defineOption({
  title: "Update page tags",
  icon: "icons:label",
  tags: ["CMS", "edit", "tags", "metadata"],
  eventName: "super-daemon-run-program",
  path: "CMS/edit/tags",
  context: ["CMS"],
  voice: "edit tags",
  value: {
    name: "Update page tags",
    machineName: "edit-tags",
    placeholder: "Enter tags separated by commas",
    // Pre-fill the input with the active page's current tags so the
    // user can edit them in place when launched from Merlin search.
    initialValue: (values) => {
      const activeItem = toJS(store.activeItem);
      return (
        (activeItem && activeItem.metadata && activeItem.metadata.tags) || ""
      );
    },
    program: async (input, values) => {
      const { createEditTagsProgram } = await import(
        "./utils/EditTagsProgram.js"
      );
      const editTagsProgram = createEditTagsProgram(this);
      return editTagsProgram(input, values);
    },
  },
});

2. Factory module (EditTagsProgram.js)

import { store } from "@haxtheweb/haxcms-elements/lib/core/haxcms-site-store.js";
import { toJS } from "mobx";

export const createEditTagsProgram = (context) => {
  return async (input) => {
    const results = [];
    const activeItem = toJS(store.activeItem);

    if (!activeItem || !activeItem.id) {
      return [
        {
          title: "No active page found",
          icon: "icons:warning",
          tags: ["error"],
          value: { disabled: true },
          eventName: "disabled",
          path: "CMS/edit/tags/error",
        },
      ];
    }

    // User typed something — offer to save their input verbatim.
    if (input && input.trim() !== "") {
      results.push({
        title: `Save tags: ${input}`,
        icon: "icons:check",
        tags: ["confirm", "save"],
        value: {
          target: globalThis,
          method: "dispatchEvent",
          args: [
            new CustomEvent("haxcms-save-node-details", {
              bubbles: true,
              composed: true,
              detail: {
                id: activeItem.id,
                operation: "setTags",
                tags: input,
              },
            }),
          ],
        },
        eventName: "super-daemon-element-method",
        path: "CMS/edit/tags/confirm",
      });
      return results;
    }

    // No input but page has tags — offer re-save and clear.
    const currentTags =
      activeItem.metadata && activeItem.metadata.tags
        ? activeItem.metadata.tags
        : "";

    if (currentTags) {
      results.push({
        title: `Save tags: ${currentTags}`,
        icon: "icons:check",
        tags: ["confirm", "save"],
        value: {
          target: globalThis,
          method: "dispatchEvent",
          args: [
            new CustomEvent("haxcms-save-node-details", {
              bubbles: true,
              composed: true,
              detail: {
                id: activeItem.id,
                operation: "setTags",
                tags: currentTags,
              },
            }),
          ],
        },
        eventName: "super-daemon-element-method",
        path: "CMS/edit/tags/confirm",
      });
      results.push({
        title: "Clear all tags",
        icon: "icons:clear",
        tags: ["confirm", "clear"],
        value: {
          target: globalThis,
          method: "dispatchEvent",
          args: [
            new CustomEvent("haxcms-save-node-details", {
              bubbles: true,
              composed: true,
              detail: { id: activeItem.id, operation: "setTags", tags: "" },
            }),
          ],
        },
        eventName: "super-daemon-element-method",
        path: "CMS/edit/tags/clear",
      });
      return results;
    }

    // No input and no tags.
    results.push({
      title: "No tags set",
      icon: "icons:info",
      tags: ["empty"],
      value: { disabled: true },
      eventName: "disabled",
      path: "CMS/edit/tags/empty",
    });
    return results;
  };
};

What happens at runtime

  1. The user opens Merlin (Alt+Shift) and types edit tags (or says "edit tags").
  2. The Update page tags entry row appears in the filtered list.
  3. The user selects it. super-daemon-row dispatches super-daemon-run-program with detail.value — the runtime calls runProgram() which enters program mode.
  4. Because no explicit like/search was supplied, runProgram calls value.initialValue(values) to pre-fill the input with the active page's current tags (e.g. "physics, lab").
  5. The program function runs with that pre-filled input and returns a Save tags: physics, lab row (plus a Clear all tags row).
  6. The user edits the text. Each keystroke is debounced ~150ms, then program re-runs with the new input and the results live-update.
  7. The user selects Save tags: .... super-daemon-row dispatches super-daemon-element-method, the runtime calls globalThis.dispatchEvent(CustomEvent("haxcms-save-node-details", ...)), the tags are saved, and the palette closes (because the event was not super-daemon-run-program).

License

Apache-2.0

About

Documenting the program schema for merlin command system built on top of super-daemon webcomponents

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors