---
title: Command Palette
description: A Cmd+K command palette for actions, links, and typed input commands.
sidebar:
  order: 8
---

import { Demo1 } from "./command-palette.demos";

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

```package-install
npm install @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

```ts
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.

<Preview
  code={
    'import { html, ilha } from "ilha";\nimport { CommandPalette } from "@areia/cmd";\n\nconst AppCommands = CommandPalette(\n  [\n    {\n      id: "refresh_dashboard",\n      label: "Refresh dashboard",\n      group: "Actions",\n      run: ({ signal }) => refreshDashboard({ signal }),\n    },\n    {\n      id: "open_billing",\n      label: "Open billing settings",\n      group: "Navigation",\n      href: "/settings/billing",\n    },\n  ],\n  {\n    hotkey: false,\n    trigger: html`<button type="button">Open commands</button>`,\n  },\n);\n\nexport default ilha(() => <AppCommands />);'
  }
  lang="tsx"
>
  <Demo1 client:load />
</Preview>

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

## Actions and links

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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```ts
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](https://standardschema.dev/schema) for validation and [Standard JSON Schema](https://standardschema.dev/json-schema) for discovery. Zod implements both.

```tsx
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.

```tsx
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.

```tsx
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.

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

const BareCommands = CommandPaletteBase(commands);
```

```css
[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.
