# How to add a keyboard command bar over your API operations

> Build a command palette over every operation in an OpenAPI document, opening in the sandbox and refusing a delete until its operationId is typed back.

Source: https://voxgig.com/howto/keyboard-command-bar-over-api-operations

- Audience: platform-team
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Build the item list from the OpenAPI document at build time, one item per operationId with the summary, tags, method, and path as search keywords. Render it with cmdk, with kbar, or in a plain dialog element over a filtered list. Open the palette in the sandbox, badge every write, and refuse to run a delete until the user types its operationId back.

---
## You will need

An OpenAPI 3 document in which every operation carries an `operationId`, a web app you can add a
component to, and Node 22 or later for the build step. Verified 2026-10-21 against Node 22.22.0,
cmdk 1.1.1, kbar 1.0.0, fuse.js 7.5.0, react 19.3.1, and esbuild 0.28.2. The sample builds the
list, drives the guard as a state machine, and measures both libraries from Node with the
[Node test runner](https://nodejs.org/api/test.html). cmdk and kbar are React components. The
baseline is the
[`dialog` element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog),
which needs no dependency.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A plain HTML dialog element with a filtered list](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog) | No React, or a list short enough that plain substring matching finds things | Focus handling, the ARIA wiring, and any ranking are yours to write and to keep working in every browser | Users type initials and typos and expect a match |
| [cmdk](https://github.com/pacocoursey/cmdk) | A React app that wants ranked matching and the `combobox` roles wired, in about 50 KB | Radix Dialog in the bundle, a ranking that puts `listMeterReadings` under `del`, and styling from zero | You need sections ranked by priority or actions registered from deep in the tree |
| [kbar](https://github.com/timc1/kbar) | A React app with many actions, nested actions, sections, and shortcuts registered from anywhere in the tree | About twice the bytes of cmdk, through Fuse.js and list virtualization, and a matcher that returns nothing for the initials `lm` | A short list, where a 110 KB palette is most of the page |

The two libraries ship the same shape: an input with `role="combobox"`, a `listbox`, and `option`
rows. cmdk ranks by subsequence, so `lm` finds `listMeters` but `del` also drags in
`listMeterReadings`. kbar ranks with Fuse.js, which forgives the typo `metre` because it scores
edit distance, so the initials `lm` return nothing. The plain dialog matches each word of the query
as a substring, in any order, which is enough until the list outgrows what people can read.

## Build the item list once, from the document

Everything the palette shows comes from the OpenAPI document, so build the list in the step that
builds the app, never at runtime in the browser. One item per operation: the `operationId` is the
id, the `summary` is the text, and the tags, the method, and the path segments become keywords.

```js title="items.mjs"
export function buildItems(doc) {
  const items = []
  for (const [path, ops] of Object.entries(doc.paths)) {
    for (const method of METHODS) {
      const op = ops[method]
      if (!op) continue
      if (!op.operationId) throw new Error(`${method.toUpperCase()} ${path} has no operationId`)
      items.push({
        id: op.operationId,
        label: op.summary ?? op.operationId,
        method: method.toUpperCase(),
        path,
        keywords: [...(op.tags ?? []), method, ...path.split('/').filter((s) => s && !s.startsWith('{'))],
        mutating: WRITES.has(method),
        // The method says DELETE. It does not say that a POST to /purge destroys a tenant,
        // so the spec gets to say that too.
        destructive: method === 'delete' || op['x-destructive'] === true,
        deprecated: Boolean(op.deprecated),
      })
    }
  }
  return items
}
```

The `operationId` is required rather than defaulted. An operation without one has no stable name
for a palette, a CLI, or a generated client, and
[the OpenAPI Specification](https://spec.openapis.org/oas/v3.1.0.html#operation-object) requires
the ids a document does carry to be unique.[^2] A missing id fails the build rather than the
palette.

```bash
node doc.mjs
```

```text output
listMeters         GET     /meters                           READ
createMeter        POST    /meters                           WRITE
getMeter           GET     /meters/{meterId}                 READ
updateMeter        PATCH   /meters/{meterId}                 WRITE
deleteMeter        DELETE  /meters/{meterId}                 DELETE
listMeterReadings  GET     /meters/{meterId}/readings        READ
recordReading      POST    /meters/{meterId}/readings        WRITE
listInvoices       GET     /invoices                         READ
voidInvoice        POST    /invoices/{invoiceId}/void        WRITE
purgeTenant        POST    /admin/tenants/{tenantId}/purge   DELETE
10 operations, 6 writes, 2 destructive
```

Two of the ten carry the `DELETE` badge. One earns it from the HTTP method. The other is
`purgeTenant`, a POST that destroys a tenant, marked `x-destructive` in the document because the
method alone would call it a write.

## Measure the two libraries before choosing one

Run the same five queries through each ranking. cmdk exports `defaultFilter`, the function its
`Command` component ranks with. kbar ranks inside a React hook with Fuse.js, so the script runs
Fuse with the options copied from kbar's installed source, including the
[token search](https://www.fusejs.io/token-search.html) that lets a query span fields.

```js title="rank.mjs"
// kbar ranks with Fuse.js inside its useMatches hook, which needs a React render to call.
// These are the options the hook passes, copied from the installed package, so this is
// kbar's ranking without the component around it.
const KBAR_FUSE_OPTIONS = {
  keys: [
    { name: 'name', weight: 0.5 },
    { name: 'keywords', getFn: (a) => (a.keywords ?? '').split(','), weight: 0.3 },
    { name: 'subtitle', weight: 0.2 },
  ],
  ignoreLocation: true,
  ignoreDiacritics: true,
  includeScore: true,
  threshold: 0.2,
  minMatchCharLength: 1,
  useTokenSearch: true,
  tokenMatch: 'all',
}
const actions = items.map((it) => ({ id: it.id, name: it.label, keywords: keywordsOf(it).join(','), subtitle: `${it.method} ${it.path}` }))
const fuse = new Fuse(actions, KBAR_FUSE_OPTIONS)

export const rankKbar = (query) => fuse.search(query).map((r) => r.item.id)
```

```bash
node rank.mjs
```

```text output
"del"
  cmdk   deleteMeter listMeterReadings
  kbar   deleteMeter
  plain  deleteMeter
"void invoice"
  cmdk   voidInvoice
  kbar   voidInvoice
  plain  voidInvoice
"lm"
  cmdk   listMeters listMeterReadings deleteMeter purgeTenant
  kbar   (nothing)
  plain  (nothing)
"metre"
  cmdk   listMeterReadings recordReading createMeter listMeters getMeter updateMeter deleteMeter purgeTenant
  kbar   listMeters createMeter getMeter updateMeter deleteMeter listMeterReadings recordReading
  plain  (nothing)
"readings post"
  cmdk   recordReading
  kbar   recordReading
  plain  recordReading
```

Read the five blocks as a choice of failure. A prefix, `del`, and two words in the wrong order,
`void invoice`, work everywhere. Initials, `lm`, work in cmdk alone, because it matches a
subsequence, and that same rule is why `del` also ranks `listMeterReadings`. The typo `metre`
works in both libraries, since Fuse scores edit distance[^3] and a subsequence of `metre` exists in
most labels. The plain filter finds nothing for either.

Bundle weight comes from the [esbuild API](https://esbuild.github.io/api/), with React left out of
every figure.

```bash
node size.mjs
```

```text output
cmdk             52374 bytes minified, React excluded
kbar            113842 bytes minified, React excluded
fuse.js alone    26530 bytes minified, React excluded
dialog.mjs        2077 bytes minified, React excluded
```

kbar brings Fuse.js and list virtualization, which is most of the gap. The last row is the
baseline, `dialog.mjs` with `palette.mjs` behind it.

The roles come from the markup each library renders with
[`renderToStaticMarkup`](https://react.dev/reference/react-dom/server/renderToStaticMarkup).

```bash
node aria.mjs
```

```text output
cmdk
  input   aria-autocomplete role="combobox" aria-expanded aria-controls aria-labelledby
  list    role="listbox" aria-label
  options 10 rendered with role="option"
kbar
  input   role="combobox" aria-expanded aria-controls aria-activedescendant
  list    role="listbox"
  options 0 rendered with role="option"
```

Both render the [`combobox` pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/): an input
with `role="combobox"` that keeps focus, a `listbox`, and `option` rows.[^4] kbar renders no rows
on the server because its list is virtualized and measures itself in the browser, so its
`role="option"` and `aria-selected` per row come from its source rather than from this output. The
baseline gets the same roles only if you type them, which `palette.html` does.

## Open in the sandbox and make a delete cost a word

The palette's cost is the same thing as its value: every operation is two keystrokes away,
including the ones that destroy data. The state machine that decides what Enter does is the part
to test, and it has no DOM in it.

```js title="palette.mjs"
export function decide(item, { env, typed = '' }) {
  if (!item) return { run: false, reason: 'nothing selected' }
  const needsTyping = item.destructive || (env !== 'sandbox' && item.mutating)
  if (needsTyping && typed !== item.id) {
    return { run: false, confirm: true, reason: `type ${item.id} to confirm` }
  }
  return { run: true }
}

export function enter(state, run) {
  const item = state.confirm?.item ?? visible(state)[state.index]
  const verdict = decide(item, { env: state.env, typed: state.confirm?.typed ?? '' })
  if (verdict.run) {
    run(item, state.env)
    return { ...state, open: false, confirm: null, notice: `ran ${item.id} against ${state.env}` }
  }
  const confirm = verdict.confirm ? { item, typed: '' } : null
  return { ...state, confirm, notice: verdict.reason }
}
```

Three rules, in order of how often they fire. The palette opens against the server the document
describes as `sandbox`, and production is a switch the user flips on purpose. A destructive
operation asks for its own id typed back, in every environment, and a near miss such as
`deletemeter` is not a confirmation. Outside the sandbox, every write asks the same question, while a
read runs on Enter alone.

The check compares with `sandbox`, not `production`, so a server the document calls `Production` or
`staging` is guarded too.

The browser side is thin. The input inside the dialog takes the keystrokes, and
[`showModal()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal)
puts the dialog in the top layer with the rest of the page inert behind it.

```js title="dialog.mjs"
  input.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') {
      state = enter(state, run)
      input.value = ''
    } else {
      state = press(state, event.key)
    }
    render()
  })
  document.addEventListener('keydown', (event) => {
    if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
      event.preventDefault()
      state = { ...openPalette(items, { env: state.env }) }
      dialog.showModal()
      input.focus()
      render()
    }
  })
```

```html title="palette.html"
<dialog id="ops" data-env="sandbox">
  <input type="text" role="combobox" aria-controls="ops-list" aria-expanded="true" autocomplete="off">
  <ul id="ops-list" role="listbox"></ul>
</dialog>
```

The `data-env` attribute is the default the palette opens with. Production appears nowhere in the
markup, because switching to it is something the user does on purpose in the UI.

## Check it worked

Run the tests. They assert on what ran, not on what the palette displayed.

```bash
node --test palette.test.mjs
```

```text output
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 99.425418
```

`# pass 8` covers the three that matter: `deleteMeter` does not run on Enter, does not run on
`deletemeter`, and runs on `deleteMeter`. A fourth asserts that `purgeTenant`, a POST, needs the
same word. A fifth asserts that a write asks for its id under every environment name but `sandbox`.

## When it goes wrong

Enter deletes a record in production. The palette was built the obvious way, pointed at the first
server in the document and running whatever row was selected. Both palettes below receive the same
keystrokes.

```bash
node pitfall.mjs
```

```text output
unguarded palette, pointed at production
  typed "del", pressed Enter
  ran deleteMeter: DELETE https://api.meterco.example/v1/meters/{meterId}
palette.mjs
  typed "del", pressed Enter
  type deleteMeter to confirm (environment sandbox)
  typed "deleteMeter", pressed Enter
  ran deleteMeter against sandbox: DELETE https://sandbox.meterco.example/v1/meters/{meterId}
2 requests sent, the second one to the sandbox
```

The first palette sent a DELETE to production on four keystrokes. The second asked for twelve more,
the eleven letters of `deleteMeter` and Enter, and sent it to the sandbox.

A destructive POST wears the `WRITE` badge. The item builder had only the HTTP method to go on, so
`purgeTenant` looked like any other write until the document said otherwise. OpenAPI has no
standard flag for this, so the sample reads an `x-destructive` extension; MCP's
[`destructiveHint`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-annotations)
is the same idea in another protocol. Mark the operation in the document, where the next tool reads
it too.

Initials find nothing. kbar's token matcher and the plain filter both return nothing for `lm`,
because neither matches a subsequence. Add the aliases people use to the keywords, `lm` beside
`listMeters`, rather than changing libraries for one query shape.

## When not to do this

Do not build a palette for an app with eight operations. A menu with eight entries is faster to
learn than a search box with eight answers.

Do not treat the typed confirmation as authorization. It stops a slip of the hand. It does nothing
against a person who means to delete the record, and the API's own permissions are where that
decision belongs. A palette user should hold a token that can do only what the sandbox allows
until they switch environments on purpose.

Do not expose a customer-facing API this way. In front of customers the palette is a second UI to
keep consistent with the first.

Do not ship kbar or cmdk into a page without React. The baseline `dialog` is two kilobytes.

Do not skip the build step and read the OpenAPI document in the browser. The list changes when the
document changes, which is a deploy, and a palette that fetches its own menu at runtime shows an
empty list when that fetch fails.

## Related how-tos

- [Derive a noun-verb command tree from an OpenAPI document](/howto/derive-a-command-tree-from-operation-ids)

- [Write operationIds that generate clean SDK method names](/howto/operationids-that-generate-clean-sdk-method-names)

- [Require human approval before an agent calls a delete tool](/howto/require-approval-before-a-delete-tool-call)

- [Preload a generated client into a Node REPL](/howto/preload-a-generated-client-into-a-node-repl)

## Last verified

Verified 2026-10-21 against Node 22.22.0, cmdk 1.1.1, kbar 1.0.0, fuse.js 7.5.0, react 19.3.1,
react-dom 19.3.1, and esbuild 0.28.2. Every output block is what the command preceding it printed.
The two libraries were measured from Node: their ranking functions called directly, their markup
rendered with `renderToStaticMarkup`, and their bundles built with esbuild. kbar's ranking was
reproduced with Fuse.js and the options its `useMatches` hook passes, read from the installed
package. No browser was driven, so each library's keyboard handling was read from its source, and
`dialog.mjs` and `palette.html` were not executed.

[^1]: `showModal()` is the half of the element that earns the baseline its place.
[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) describes it
as showing the dialog as a modal, over the top of anything else that might be visible.
[The HTML Standard](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element)
defines the method's steps as showing a modal dialog, in a section that also specifies the
`::backdrop` and the inertness of everything outside it. Trapping focus, the part every palette
library once wrote by hand, is the browser's job in that sentence.

[^2]: The specification's paragraph on `operationId` uses three verbs of different strength. The
id MUST be unique among all operations described in the API, and the value is case-sensitive.
Following common programming naming conventions is RECOMMENDED, since tools MAY use the id to
identify an operation. A palette is one of those tools.
[The Operation Object](https://spec.openapis.org/oas/v3.1.0.html#operation-object) says all of
this in four sentences.

[^3]: Fuse scores a match between 0, a perfect match, and 1, which matches anything, and cuts off
at `threshold`, 0.6 by default according to
[its fuzzy search page](https://www.fusejs.io/fuzzy-search.html). kbar passes 0.2 with
`ignoreLocation: true`, so a match may sit anywhere in the field and has to be close. `metre`
against `meter` is one letter out of place, which is close enough. `lm` against `List meters` is
two letters with a word between them, which is no pattern at all.

[^4]: The [`combobox` pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) keeps DOM
focus on the text box while assistive technology focus moves through the list with
`aria-activedescendant`, as the Authoring Practices Guide puts it. In that server-rendered markup
only kbar's input carries the attribute. cmdk's source sets it in the browser, to the id of the row
it marks with `aria-selected`, and on the server no row is selected yet: all ten render
`aria-selected="false"`.