Skip to content
Areia
Esc
navigateopen⌘Jpreview
On this page

Command Palette

A Cmd+K command palette for actions, links, and typed input commands.

You declare commands once and render the returned island. Open it with a trigger in this guide. Selecting an input command opens a generated Areia form. The same command can register as a WebMCP tool so an agent runs the validated operation in this page and session.

Installation

npm install @areia/cmd
pnpm add @areia/cmd
yarn add @areia/cmd
bun add @areia/cmd

Peer dependencies: areia, ilha, @areia/slots, and @areia/form. Install @standard-schema/spec when you declare input commands; your schema library implements it.

For a single package with no Ilha, Areia, or Tailwind install, import @areia/cmd/standalone and @areia/cmd/standalone.css. That bundle includes Ilha (html, ilha, mount) so you do not add those packages. Do not mix it with @areia/cmd on the same page.

Import

import {
  CommandPalette,
  CommandPaletteBase,
  type InputCommand,
} from "@areia/cmd";

Usage

Call CommandPalette with your commands and render the island. The factory keeps commands and callbacks in its closure, so they never serialize through data-ilha-props. Filtering, ranking, keyboard navigation, and ARIA state come from the @areia/slots command primitive. Dialog behavior comes from the @areia/slots dialog primitive.

import { html, ilha } from "ilha";
import { CommandPalette } from "@areia/cmd";

const AppCommands = CommandPalette(
  [
    {
      id: "refresh_dashboard",
      label: "Refresh dashboard",
      group: "Actions",
      run: ({ signal }) => refreshDashboard({ signal }),
    },
    {
      id: "open_billing",
      label: "Open billing settings",
      group: "Navigation",
      href: "/settings/billing",
    },
  ],
  {
    hotkey: false,
    trigger: html`<button type="button">Open commands</button>`,
  },
);

export default ilha(() => <AppCommands />);

Try Search settings in the palette to open the generated form.

An action command has run. A link command has href. Give each command a unique id — it is the selection value and the WebMCP tool name.

const commands = [
  {
    id: "sign_out",
    label: "Sign out",
    run: () => signOut(),
  },
  {
    id: "open_billing",
    label: "Open billing settings",
    href: "/settings/billing",
  },
];

Selection closes the palette before run executes. You own progress and errors — surface them through your toast or state layer. Link commands navigate with window.location.assign. Set external: true to open a new tab with rel="noopener noreferrer". External links render a after the label.

const commands = [
  {
    id: "open_docs",
    label: "Open documentation",
    href: "https://ilha.build",
    external: true,
  },
];

Groups, keywords, icons, and shortcuts

Commands with the same group share a heading. Groups appear in the order of their first command; commands keep authored order. Commands without group have no heading. keywords extend the filter, icon renders before the label, and shortcut is display text only.

const commands = [
  {
    id: "refresh_dashboard",
    label: "Refresh dashboard",
    description: "Reload the latest dashboard data.",
    group: "Actions",
    keywords: ["reload", "sync"],
    shortcut: "⌘R",
    icon: <RefreshIcon />,
    run: ({ signal }) => refreshDashboard({ signal }),
  },
];

Open the palette

The palette toggles on Cmd+K or Ctrl+K by default. Pass hotkey: false when you open it yourself. Prefer trigger for a button — the dialog controller wires click, ARIA, and focus return.

import { html } from "ilha";

const AppCommands = CommandPalette(commands, {
  hotkey: false,
  trigger: html`<button type="button">Open commands</button>`,
});

Dispatch events on that palette’s dialog root when another controller owns the trigger. Events stay scoped to that palette.

const palette = document.querySelector<HTMLElement>(
  '[data-slot="dialog"]',
);

palette?.dispatchEvent(new CustomEvent("command-palette:open"));
palette?.dispatchEvent(
  new CustomEvent("command-palette:toggle"),
);
palette?.dispatchEvent(
  new CustomEvent("command-palette:close"),
);

Collect command input

Add an InputCommand when the operation needs structured input. Selecting it opens a second step generated by @areia/form. Agents get the same command as a WebMCP tool.

The schema must implement Standard Schema for validation and Standard JSON Schema for discovery. Zod implements both.

import { CommandPalette, type InputCommand } from "@areia/cmd";
import { z } from "zod";

const searchInput = z.object({
  query: z
    .string()
    .min(1)
    .describe("Settings text to search for"),
  limit: z.number().int().min(1).max(20).default(5),
});

const searchSettings = {
  id: "search_settings",
  label: "Search settings",
  description: "Find a setting by name.",
  input: searchInput,
  defaultValues: { query: "", limit: 5 },
  submitLabel: "Search",
  webmcp: {
    description:
      "Search the signed-in user's application settings.",
    annotations: { readOnlyHint: true },
  },
  run: async ({ query, limit }, { signal }) =>
    searchApplicationSettings({ query, limit, signal }),
} satisfies InputCommand<typeof searchInput>;

const AppCommands = CommandPalette([
  ...commands,
  searchSettings,
]);

A valid submission closes the palette. Escape or the back arrow returns you to the command list without closing the palette. Configure the form with defaultValues, uiOverrides, and submitLabel. WebMCP calls convert the schema to JSON Schema and validate before run. Both paths receive the schema’s output. Keep authorization inside run.

Expose commands to agents with WebMCP

Without webmcp, a command stays private to the palette. Add a webmcp object with an agent-facing description to register it through document.modelContext. That includes input commands — omit webmcp if the form is only for people.

const commands = [
  {
    id: "refresh_dashboard",
    label: "Refresh dashboard",
    run: ({ signal }) => refreshDashboard({ signal }),
    webmcp: {
      description:
        "Refresh the signed-in user's dashboard data.",
      annotations: {
        readOnlyHint: true,
        untrustedContentHint: true,
      },
    },
  },
];

WebMCP is progressive enhancement. When document.modelContext is missing, the palette still works — nothing warns, throws, or polyfills. Disabled commands never register. Return enough from run for an agent to verify the outcome.

Handle execution context

run receives the invocation source and an abort signal. Pass signal to cancellable work. Use source only when presentation differs; keep authorization identical.

const commands = [
  {
    id: "refresh_dashboard",
    label: "Refresh dashboard",
    run: async ({ source, signal }) => {
      await refreshDashboard({ signal });
      if (source === "palette")
        showToast("Dashboard refreshed");
      return { refreshed: true };
    },
  },
];

Security

  • Treat webmcp as an exposure boundary, not presentation metadata.
  • Keep authentication, authorization, validation, and audit inside run.
  • State consequential side effects in the agent-facing description.
  • readOnlyHint and untrustedContentHint are hints, not controls.

Headless flavor

CommandPaletteBase uses the same commands, options, controllers, hotkey, trigger, form step, and WebMCP wiring with unstyled slot markup.

import { CommandPaletteBase } from "@areia/cmd";

const BareCommands = CommandPaletteBase(commands);
[data-slot="command-item"][data-selected="true"] {
  background: var(--areia-primary-soft);
  color: var(--areia-primary-soft-foreground);
}

Command properties

Prop Type Required Description
id string Yes Unique selection value and WebMCP tool name.
label string Yes Visible command label.
run (context) => unknown | Promise<…> Action Executes an action command.
href string Link Navigates when the command is selected.
external boolean No Opens a link in a new tab.
description string No Supporting text shown below the label.
group string No Groups commands under a shared heading.
keywords readonly string[] No Additional filtering terms.
shortcut string No Display-only shortcut text.
icon RawHtml | string No Content rendered before the label.
disabled boolean No Prevents palette execution and WebMCP registration.
webmcp WebMCPExposure No Exposes a command through WebMCP.
input CommandInputSchema Input Validates form input and provides agent JSON Schema.
defaultValues InferOutput<Schema> No Initial values for the generated form.
uiOverrides UIOverrides No Overrides inferred @areia/form fields.
submitLabel string No Generated form submit button text.

A basic command defines exactly one of run or href. An InputCommand defines input and run. Add webmcp when an agent should call it too.

Options

Prop Type Default Description
label string "Command palette" Accessible name for the dialog and command input.
placeholder string "Type a command or search…" Input placeholder text.
empty string "No results found." Text shown when the filter matches nothing.
trigger RawHtml Click-to-open trigger content, rendered as [data-slot="dialog-trigger"].
hotkey "mod+k" | false "mod+k" Global Cmd+K / Ctrl+K toggle. false disables it.
loop boolean false Wrap arrow-key selection from the last item to first.

Cleanup

Unmount destroys both controllers, removes the hotkey listener, and aborts WebMCP registrations. Mount one palette per command set so tool names stay unique.

Was this page helpful?